mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3bb81bfab | ||
|
|
187b088013 | ||
|
|
5046b5ab44 | ||
|
|
e8137b42cc | ||
|
|
cb5dc148d5 | ||
|
|
3c5f838c56 | ||
|
|
9337bd557b | ||
|
|
162a31ae92 | ||
|
|
2f84234a31 |
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
windows-daemon:
|
windows-daemon:
|
||||||
name: Windows daemon regression tests
|
name: Windows daemon and update regression tests
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -52,6 +52,12 @@ jobs:
|
|||||||
- name: Run Windows process liveness tests
|
- name: Run Windows process liveness tests
|
||||||
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
||||||
|
|
||||||
|
- name: Run Windows update tests
|
||||||
|
run: cargo test -p bsk --lib --locked cli::update::tests
|
||||||
|
|
||||||
|
- name: Run Windows update process tests
|
||||||
|
run: cargo test -p bsk --test windows_update --locked
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -147,20 +147,26 @@ Start a new Agent session and write a prompt that needs the browser, for example
|
|||||||
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
||||||
BrowserSkill ships a first-class dsh plugin on npm as
|
BrowserSkill ships a first-class dsh plugin on npm as
|
||||||
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
||||||
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
|
It gives the agent native `browser_*` tools and a live view of its browser sessions
|
||||||
overlay of each Agent Window.
|
in the Web UI. The plugin runs `bsk` on the agent's behalf.
|
||||||
|
|
||||||
Add it to a dsh profile, then start that profile:
|
Install the `bsk` CLI and connect the browser extension first. Then add the plugin
|
||||||
|
to a dsh profile and start it (replace `web` with your profile name):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||||
dsh --profile web
|
dsh --profile web
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
|
The plugin includes the `browser-skill` skill, so `bsk install-skill` is not needed
|
||||||
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
|
for dsh. Installed plugins do not update automatically. To upgrade this plugin:
|
||||||
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
|
|
||||||
configuration, and the observation overlay.
|
```sh
|
||||||
|
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the profile after upgrading. See the
|
||||||
|
[plugin README](packages/dsh-plugin-browserskill/README.md) for usage and configuration.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -125,16 +125,22 @@ bsk install-skill
|
|||||||
|
|
||||||
## DeepSeek Harness 插件
|
## DeepSeek Harness 插件
|
||||||
|
|
||||||
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
|
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它为 Agent 提供原生 `browser_*` 工具,由插件代为调用 `bsk`,并在 Web UI 中实时展示浏览器会话。
|
||||||
|
|
||||||
把它装进某个 dsh profile,然后启动该 profile:
|
先安装 `bsk` CLI 并连接浏览器扩展,再将插件装进 dsh profile 并启动(将 `web` 替换为你的 profile 名称):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||||
dsh --profile web
|
dsh --profile web
|
||||||
```
|
```
|
||||||
|
|
||||||
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
插件自带 `browser-skill` skill,所以在 dsh 下无需执行 `bsk install-skill`。已安装的插件不会自动更新;升级此插件请运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
升级后重启该 profile。用法与配置见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
mod windows;
|
||||||
#[cfg(windows)]
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
|
|||||||
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
|
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
|
let restart_args = daemon_was_running.then(StartArgs::default);
|
||||||
|
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
|
||||||
|
|
||||||
if daemon_was_running && matches!(action, InstallAction::Replaced) {
|
if daemon_was_running && matches!(action, InstallAction::Replaced) {
|
||||||
crate::daemon::start::run_start(StartArgs::default())
|
crate::daemon::start::run_start(StartArgs::default())
|
||||||
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
|
|||||||
|
|
||||||
/// Daemon-side install: download, verify, and replace the executable at
|
/// Daemon-side install: download, verify, and replace the executable at
|
||||||
/// `target` (on Windows: stage the replacement next to it). Unlike the
|
/// `target` (on Windows: stage the replacement next to it). Unlike the
|
||||||
/// CLI path this never stops or starts the daemon — the daemon drives
|
/// CLI path this never stops the daemon. On Windows the helper starts
|
||||||
/// its own restart once the binary is replaced.
|
/// its replacement after exit; elsewhere the daemon drives its own restart.
|
||||||
///
|
///
|
||||||
/// `target` must be captured *before* any replacement happens: on Linux
|
/// `target` must be captured *before* any replacement happens: on Linux
|
||||||
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
|
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
|
||||||
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
|
|||||||
pub(crate) fn self_install_candidate(
|
pub(crate) fn self_install_candidate(
|
||||||
candidate: &UpdateCandidate,
|
candidate: &UpdateCandidate,
|
||||||
target: &Path,
|
target: &Path,
|
||||||
|
restart_args: &StartArgs,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
||||||
let binary = download_candidate_binary(candidate, &client)?;
|
let binary = download_candidate_binary(candidate, &client)?;
|
||||||
replace_binary_at_path(target, &binary)
|
replace_binary_for_update(target, &binary, Some(restart_args))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
|
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
|
||||||
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
|
|||||||
/// The new binary replaced the old one; the daemon should now
|
/// The new binary replaced the old one; the daemon should now
|
||||||
/// restart into it.
|
/// restart into it.
|
||||||
Replaced { latest: String },
|
Replaced { latest: String },
|
||||||
/// Windows staged the replacement next to the running binary; a
|
/// Windows started a helper that will replace the executable and
|
||||||
/// daemon/terminal restart finishes it.
|
/// restart the daemon once the current process exits.
|
||||||
Staged { latest: String },
|
Staged { latest: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
|
|||||||
}
|
}
|
||||||
if matches!(report.install_action, Some("staged")) {
|
if matches!(report.install_action, Some("staged")) {
|
||||||
println!("the detached update helper will apply the replacement after exit");
|
println!("the detached update helper will apply the replacement after exit");
|
||||||
|
println!("if it fails, see the *.update-*.log file next to the executable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Format::Json => {
|
Format::Json => {
|
||||||
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
||||||
replace_binary_for_update(target, binary, false)
|
replace_binary_for_update(target, binary, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_binary_for_update(
|
fn replace_binary_for_update(
|
||||||
target: &Path,
|
target: &Path,
|
||||||
binary: &[u8],
|
binary: &[u8],
|
||||||
restart_daemon: bool,
|
restart_args: Option<&StartArgs>,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
stage_windows_replacement(target, binary, restart_daemon)
|
stage_windows_replacement(target, binary, restart_args)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
let _ = restart_daemon;
|
let _ = restart_args;
|
||||||
replace_binary_atomically(target, binary)
|
replace_binary_atomically(target, binary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,34 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
|
|||||||
fn stage_windows_replacement(
|
fn stage_windows_replacement(
|
||||||
target: &Path,
|
target: &Path,
|
||||||
binary: &[u8],
|
binary: &[u8],
|
||||||
restart_daemon: bool,
|
restart_args: Option<&StartArgs>,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
let paths = staged_replacement_paths(target, std::process::id())?;
|
// The helper owns replacement/restart after this process exits. Do not
|
||||||
std::fs::write(&paths.binary_path, binary)
|
// report Staged until it has actually begun executing the script.
|
||||||
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
|
||||||
std::fs::write(
|
Ok(InstallAction::Staged)
|
||||||
&paths.script_path,
|
}
|
||||||
windows_replacement_script(target, &paths.binary_path, restart_daemon),
|
|
||||||
)
|
|
||||||
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
|
||||||
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
#[cfg(windows)]
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
fn spawn_windows_replacement(
|
||||||
let command = format!("\"{}\"", paths.script_path.display());
|
target: &Path,
|
||||||
if let Err(err) = Command::new("cmd.exe")
|
binary: &[u8],
|
||||||
.args(["/D", "/S", "/C"])
|
restart_args: Option<&StartArgs>,
|
||||||
.arg(command)
|
attempts: u32,
|
||||||
.stdin(Stdio::null())
|
) -> Result<windows::Helper> {
|
||||||
.stdout(Stdio::null())
|
let paths = staged_replacement_paths(target, std::process::id())?;
|
||||||
.stderr(Stdio::null())
|
let ready_path = paths.script_path.with_extension("ready");
|
||||||
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
|
let log_path = paths.script_path.with_extension("log");
|
||||||
.spawn()
|
let result = (|| {
|
||||||
{
|
// A previous failed attempt by this process must not acknowledge a
|
||||||
|
// new helper. All paths are private to this target and process id.
|
||||||
|
match std::fs::remove_file(&ready_path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
std::fs::write(&paths.binary_path, binary)
|
||||||
|
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
||||||
|
std::fs::write(
|
||||||
|
&paths.script_path,
|
||||||
|
windows_replacement_script(restart_args, attempts),
|
||||||
|
)
|
||||||
|
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
||||||
|
let mut child = windows::spawn(
|
||||||
|
&paths.script_path,
|
||||||
|
&paths.binary_path,
|
||||||
|
target,
|
||||||
|
&ready_path,
|
||||||
|
&log_path,
|
||||||
|
)
|
||||||
|
.context("launch detached Windows update helper")?;
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
if std::fs::remove_file(&ready_path).is_ok() {
|
||||||
|
return Ok(child);
|
||||||
|
}
|
||||||
|
if let Some(status) = child.try_wait()? {
|
||||||
|
bail!("Windows update helper exited before becoming ready: {status}");
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
bail!("Windows update helper did not become ready within 5 seconds");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
let _ = std::fs::remove_file(&paths.binary_path);
|
let _ = std::fs::remove_file(&paths.binary_path);
|
||||||
let _ = std::fs::remove_file(&paths.script_path);
|
let _ = std::fs::remove_file(&paths.script_path);
|
||||||
return Err(err).context("launch detached Windows update helper");
|
let _ = std::fs::remove_file(&ready_path);
|
||||||
}
|
}
|
||||||
Ok(InstallAction::Staged)
|
result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
|
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
|
||||||
@@ -793,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
#[cfg(any(windows, test))]
|
||||||
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
|
fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
|
||||||
let target = target.display().to_string().replace('%', "%%");
|
// Only fixed switches and numeric values go into the ASCII script. Paths
|
||||||
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
|
// stay in Unicode environment variables, including %, ! and shell symbols.
|
||||||
let restart = if restart_daemon {
|
let restart = restart_args.map_or_else(String::new, |args| {
|
||||||
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
|
format!(
|
||||||
} else {
|
"\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
|
||||||
String::new()
|
args.resolved_port(),
|
||||||
};
|
args.resolved_session_idle().as_millis(),
|
||||||
|
args.resolved_daemon_idle().as_millis(),
|
||||||
|
)
|
||||||
|
});
|
||||||
format!(
|
format!(
|
||||||
"@echo off\r\n\
|
"@echo off\r\n\
|
||||||
setlocal\r\n\
|
setlocal DisableDelayedExpansion\r\n\
|
||||||
|
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
|
||||||
|
if errorlevel 1 exit /b 1\r\n\
|
||||||
|
set /a attempts=0\r\n\
|
||||||
:retry\r\n\
|
:retry\r\n\
|
||||||
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
|
move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
|
||||||
if errorlevel 1 (\r\n\
|
if not errorlevel 1 goto replaced\r\n\
|
||||||
ping -n 2 127.0.0.1 >nul\r\n\
|
set /a attempts+=1\r\n\
|
||||||
goto retry\r\n\
|
if %attempts% geq {attempts} goto failed_replace\r\n\
|
||||||
)\r\n\
|
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
|
||||||
|
ping -n 2 127.0.0.1 >nul\r\n\
|
||||||
|
goto retry\r\n\
|
||||||
|
:failed_replace\r\n\
|
||||||
|
echo Failed to replace the executable after %attempts% attempts.\r\n\
|
||||||
|
goto failed\r\n\
|
||||||
|
:replaced\r\n\
|
||||||
{restart}\
|
{restart}\
|
||||||
del /F /Q \"%~f0\" >nul 2>nul\r\n"
|
del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
|
||||||
|
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
|
||||||
|
:failed_restart\r\n\
|
||||||
|
echo Replaced the executable but failed to restart the daemon.\r\n\
|
||||||
|
:failed\r\n\
|
||||||
|
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
|
||||||
|
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,6 +1019,7 @@ mod tests {
|
|||||||
assert_eq!(extracted, b"windows binary");
|
assert_eq!(extracted, b"windows binary");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
#[test]
|
#[test]
|
||||||
fn replaces_binary_at_path() {
|
fn replaces_binary_at_path() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -999,31 +1054,139 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
|
fn windows_replacement_script_preserves_restart_config() {
|
||||||
let script = windows_replacement_script(
|
let args = StartArgs {
|
||||||
Path::new(r"C:\Program Files\bsk.exe"),
|
port: Some(54321),
|
||||||
Path::new(r"C:\Program Files\bsk.exe.update-42"),
|
session_idle: Some(Duration::from_millis(1234)),
|
||||||
true,
|
daemon_idle: Some(Duration::from_secs(75)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let script = windows_replacement_script(Some(&args), 120);
|
||||||
|
assert!(script.is_ascii());
|
||||||
|
assert!(
|
||||||
|
script
|
||||||
|
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
|
||||||
);
|
);
|
||||||
|
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
|
||||||
assert!(script.contains(":retry"));
|
|
||||||
assert!(script.contains("move /Y"));
|
|
||||||
assert!(script.contains("ping -n 2 127.0.0.1"));
|
|
||||||
assert!(!script.contains("timeout"));
|
|
||||||
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
|
|
||||||
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[cfg(windows)]
|
||||||
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
|
fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
|
||||||
let script = windows_replacement_script(
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
Path::new(r"C:\bsk.exe"),
|
loop {
|
||||||
Path::new(r"C:\bsk.exe.update-42"),
|
if let Some(status) = child.try_wait().unwrap() {
|
||||||
false,
|
return status;
|
||||||
);
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
panic!("Windows update helper did not exit");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert!(!script.contains("daemon start"));
|
#[cfg(windows)]
|
||||||
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
#[test]
|
||||||
|
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
for name in [
|
||||||
|
"ascii",
|
||||||
|
"with space",
|
||||||
|
"中文目录",
|
||||||
|
"literal %PATH% ! & (folder)",
|
||||||
|
] {
|
||||||
|
let dir = tmp.path().join(name);
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let target = dir.join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
let status = wait_for_helper(child);
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"{name}: {:?}",
|
||||||
|
std::fs::read_to_string(paths.script_path.with_extension("log"))
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
|
||||||
|
// Successful updates leave no helper, staged binary, ready file or log.
|
||||||
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_waits_for_unlock_then_replaces() {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
// Deny delete sharing, like a running Windows executable.
|
||||||
|
let lock = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(1)
|
||||||
|
.open(&target)
|
||||||
|
.unwrap();
|
||||||
|
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
assert!(child.try_wait().unwrap().is_none());
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
|
||||||
|
drop(lock);
|
||||||
|
assert!(wait_for_helper(child).success());
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
let _lock = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(1)
|
||||||
|
.open(&target)
|
||||||
|
.unwrap();
|
||||||
|
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
|
||||||
|
assert!(!wait_for_helper(child).success());
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
|
||||||
|
assert!(!paths.binary_path.exists());
|
||||||
|
assert!(!paths.script_path.exists());
|
||||||
|
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
|
||||||
|
let log = String::from_utf8_lossy(&log_bytes);
|
||||||
|
assert!(
|
||||||
|
log.contains("Failed to replace the executable after 2 attempts."),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_reports_restart_failure_without_losing_replacement() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
// Not a valid PE executable: replacement succeeds but restart fails.
|
||||||
|
let child = spawn_windows_replacement(
|
||||||
|
&target,
|
||||||
|
b"invalid executable",
|
||||||
|
Some(&StartArgs::default()),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!wait_for_helper(child).success());
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
|
||||||
|
let log = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(
|
||||||
|
log.contains("Replaced the executable but failed to restart the daemon."),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
|
||||||
|
assert!(!paths.script_path.exists());
|
||||||
|
assert!(!paths.binary_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1282,10 +1445,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_update_step_staged_outcome_requests_no_restart() {
|
fn auto_update_step_reports_staged_handoff() {
|
||||||
// The Windows shape: the replacement is staged next to the
|
// The helper, rather than this process, starts the new executable.
|
||||||
// running binary, so the outcome must not ask for the immediate
|
// The daemon must exit to let that staged handoff finish.
|
||||||
// self-restart that `Replaced` triggers.
|
|
||||||
let candidate = test_candidate();
|
let candidate = test_candidate();
|
||||||
let outcome =
|
let outcome =
|
||||||
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
|
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Launch the update helper with only its stdio handles inherited.
|
||||||
|
//!
|
||||||
|
//! std::process::Command on Windows also inherits other inheritable handles.
|
||||||
|
//! A daemon's listening socket must not survive in the helper: it would keep
|
||||||
|
//! the port occupied after the daemon exits and prevent its replacement starting.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||||
|
use std::os::windows::process::ExitStatusExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{
|
||||||
|
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Threading::{
|
||||||
|
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
|
||||||
|
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
|
||||||
|
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
|
||||||
|
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
|
||||||
|
WaitForSingleObject,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) struct Helper(OwnedHandle);
|
||||||
|
|
||||||
|
impl Helper {
|
||||||
|
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
|
||||||
|
// SAFETY: the owned process handle remains valid for both calls.
|
||||||
|
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
|
||||||
|
WAIT_OBJECT_0 => {
|
||||||
|
let mut code = 0;
|
||||||
|
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(Some(ExitStatus::from_raw(code)))
|
||||||
|
}
|
||||||
|
WAIT_TIMEOUT => Ok(None),
|
||||||
|
_ => Err(io::Error::last_os_error()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn kill(&mut self) -> io::Result<()> {
|
||||||
|
// SAFETY: this handle belongs to the helper we created.
|
||||||
|
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wait(&mut self) -> io::Result<()> {
|
||||||
|
// Only used after kill(); do not leave a live helper on startup failure.
|
||||||
|
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
"update helper did not exit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
|
||||||
|
struct AttributeList(Vec<usize>);
|
||||||
|
|
||||||
|
impl Drop for AttributeList {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: this wrapper is constructed only after successful initialization.
|
||||||
|
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn(
|
||||||
|
script: &Path,
|
||||||
|
source: &Path,
|
||||||
|
target: &Path,
|
||||||
|
ready: &Path,
|
||||||
|
log_path: &Path,
|
||||||
|
) -> io::Result<Helper> {
|
||||||
|
let root = std::env::var_os("SystemRoot")
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
|
||||||
|
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
|
||||||
|
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
|
||||||
|
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
|
||||||
|
let mut command = wide(OsStr::new(
|
||||||
|
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
|
||||||
|
));
|
||||||
|
let overrides = [
|
||||||
|
("BSK_UPDATE_SCRIPT", script.as_os_str()),
|
||||||
|
("BSK_UPDATE_SOURCE", source.as_os_str()),
|
||||||
|
("BSK_UPDATE_TARGET", target.as_os_str()),
|
||||||
|
("BSK_UPDATE_READY", ready.as_os_str()),
|
||||||
|
("BSK_UPDATE_LOG", log_path.as_os_str()),
|
||||||
|
];
|
||||||
|
let mut env: Vec<_> = std::env::vars_os()
|
||||||
|
.filter(|(key, _)| {
|
||||||
|
let key = key.to_string_lossy();
|
||||||
|
!overrides
|
||||||
|
.iter()
|
||||||
|
.any(|(name, _)| key.eq_ignore_ascii_case(name))
|
||||||
|
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
|
||||||
|
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
env.extend(
|
||||||
|
overrides
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key.into(), value.to_owned())),
|
||||||
|
);
|
||||||
|
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
|
||||||
|
let mut environment = Vec::new();
|
||||||
|
for (key, value) in env {
|
||||||
|
environment.extend(key.encode_wide());
|
||||||
|
environment.push(b'=' as u16);
|
||||||
|
environment.extend(value.encode_wide());
|
||||||
|
environment.push(0);
|
||||||
|
}
|
||||||
|
environment.push(0);
|
||||||
|
|
||||||
|
let input = File::open("NUL")?;
|
||||||
|
let log = File::create(log_path)?;
|
||||||
|
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
|
||||||
|
for &handle in &handles {
|
||||||
|
// SAFETY: these are dedicated helper stdio handles, held until spawn
|
||||||
|
// finishes. The explicit handle list excludes every other parent handle.
|
||||||
|
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut size = 0;
|
||||||
|
// SAFETY: the first call queries the allocation size, as required by Win32.
|
||||||
|
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
|
||||||
|
if size == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
|
||||||
|
// SAFETY: storage has the requested size and pointer alignment.
|
||||||
|
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
|
||||||
|
== 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let mut attributes = AttributeList(storage);
|
||||||
|
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
|
||||||
|
if unsafe {
|
||||||
|
UpdateProcThreadAttribute(
|
||||||
|
attributes.0.as_mut_ptr().cast(),
|
||||||
|
0,
|
||||||
|
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
|
||||||
|
handles.as_ptr().cast(),
|
||||||
|
std::mem::size_of_val(&handles),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: these Win32 structs permit zero initialization; required fields
|
||||||
|
// are populated below before the process is created.
|
||||||
|
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
|
||||||
|
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
|
||||||
|
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
startup.StartupInfo.hStdInput = handles[0];
|
||||||
|
startup.StartupInfo.hStdOutput = handles[1];
|
||||||
|
startup.StartupInfo.hStdError = handles[1];
|
||||||
|
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
|
||||||
|
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
|
||||||
|
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
|
||||||
|
// terminated, and all buffers/handles remain alive for the duration of spawn.
|
||||||
|
if unsafe {
|
||||||
|
CreateProcessW(
|
||||||
|
application.as_ptr(),
|
||||||
|
command.as_mut_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
|
1,
|
||||||
|
CREATE_NO_WINDOW
|
||||||
|
| CREATE_NEW_PROCESS_GROUP
|
||||||
|
| CREATE_UNICODE_ENVIRONMENT
|
||||||
|
| EXTENDED_STARTUPINFO_PRESENT,
|
||||||
|
environment.as_ptr().cast(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&startup.StartupInfo,
|
||||||
|
&mut process,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: successful CreateProcessW transfers these two handles to us.
|
||||||
|
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
|
||||||
|
Ok(Helper(unsafe {
|
||||||
|
OwnedHandle::from_raw_handle(process.hProcess)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(value: &OsStr) -> Vec<u16> {
|
||||||
|
value.encode_wide().chain(Some(0)).collect()
|
||||||
|
}
|
||||||
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
|||||||
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
||||||
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
|
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
|
||||||
// Fired by the update check task after a successful auto-update:
|
// Fired by the update check task after a successful auto-update:
|
||||||
// the replacement daemon has already been spawned, so this
|
// the replacement daemon (or Windows update helper) is ready, so
|
||||||
// process should shut down and let it take over.
|
// this process should shut down and let it take over.
|
||||||
let restart_notify = Arc::new(tokio::sync::Notify::new());
|
let restart_notify = Arc::new(tokio::sync::Notify::new());
|
||||||
let update_check_task =
|
let update_check_task =
|
||||||
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
|
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
|
||||||
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
|
|||||||
/// task spawns the replacement daemon (see
|
/// task spawns the replacement daemon (see
|
||||||
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
|
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
|
||||||
/// shuts down and the new version takes over; on Windows the
|
/// shuts down and the new version takes over; on Windows the
|
||||||
/// replacement can only be staged, so it just logs that a restart is
|
/// helper waits for this process to exit, replaces the binary, and starts
|
||||||
/// needed.
|
/// the new daemon with the same configuration.
|
||||||
pub(crate) fn spawn_update_check_task(
|
pub(crate) fn spawn_update_check_task(
|
||||||
state: Arc<DaemonState>,
|
state: Arc<DaemonState>,
|
||||||
restart: Arc<tokio::sync::Notify>,
|
restart: Arc<tokio::sync::Notify>,
|
||||||
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|
|||||||
|candidate| {
|
|candidate| {
|
||||||
let target =
|
let target =
|
||||||
exe_path.as_deref().context("current executable unknown")?;
|
exe_path.as_deref().context("current executable unknown")?;
|
||||||
update::self_install_candidate(candidate, target)
|
update::self_install_candidate(
|
||||||
|
candidate,
|
||||||
|
target,
|
||||||
|
&restart_start_args(&state.config),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
|
|||||||
sessions,
|
sessions,
|
||||||
"auto-update postponed: agent session(s) active; will retry on the next tick"
|
"auto-update postponed: agent session(s) active; will retry on the next tick"
|
||||||
),
|
),
|
||||||
update::AutoUpdateOutcome::Staged { latest } => warn!(
|
update::AutoUpdateOutcome::Staged { latest } => {
|
||||||
%latest,
|
info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
|
||||||
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
|
restart.notify_one();
|
||||||
),
|
return;
|
||||||
|
}
|
||||||
update::AutoUpdateOutcome::Replaced { latest } => {
|
update::AutoUpdateOutcome::Replaced { latest } => {
|
||||||
info!(
|
info!(
|
||||||
current = env!("CARGO_PKG_VERSION"),
|
current = env!("CARGO_PKG_VERSION"),
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Exercise self-update with a real executable and a local release server.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bsk::daemon::info::DaemonInfo;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const MARKER: &[u8] = b"windows-update-regression-fixture";
|
||||||
|
|
||||||
|
struct ReleaseServer {
|
||||||
|
url: String,
|
||||||
|
requests: Arc<AtomicUsize>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReleaseServer {
|
||||||
|
fn new(binary: &[u8]) -> Self {
|
||||||
|
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
archive
|
||||||
|
.start_file(
|
||||||
|
"bsk.exe",
|
||||||
|
zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Stored),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
archive.write_all(binary).unwrap();
|
||||||
|
let archive = archive.finish().unwrap().into_inner();
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": "999.0.0",
|
||||||
|
"assets": {"windows-x64": {
|
||||||
|
"url": format!("{url}/bsk.zip"),
|
||||||
|
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||||
|
}},
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker_stop = Arc::clone(&stop);
|
||||||
|
let requests = Arc::new(AtomicUsize::new(0));
|
||||||
|
let worker_requests = Arc::clone(&requests);
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
while !worker_stop.load(Ordering::SeqCst) {
|
||||||
|
let (mut stream, _) = match listener.accept() {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => panic!("accept release request: {err}"),
|
||||||
|
};
|
||||||
|
// Windows accept() inherits the listener's nonblocking mode.
|
||||||
|
// Keep accept polling for shutdown, but read/write each request
|
||||||
|
// in blocking mode with the bounded timeouts below.
|
||||||
|
stream.set_nonblocking(false).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut chunk = [0; 1024];
|
||||||
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
let len = stream.read(&mut chunk).unwrap();
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&chunk[..len]);
|
||||||
|
}
|
||||||
|
let body = if request.starts_with(b"GET /bsk.zip ") {
|
||||||
|
worker_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
&archive
|
||||||
|
} else {
|
||||||
|
&manifest
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(body).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
url: format!("{url}/version.json"),
|
||||||
|
requests,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReleaseServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
let _ = self.worker.take().unwrap().join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
|
||||||
|
let server = ReleaseServer::new(b"test binary");
|
||||||
|
let address = server
|
||||||
|
.url
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.unwrap()
|
||||||
|
.strip_suffix("/version.json")
|
||||||
|
.unwrap();
|
||||||
|
let mut stream = TcpStream::connect(address).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
// Give accept() time to run before any bytes arrive, then split the
|
||||||
|
// headers across writes. Both reads must wait rather than return WouldBlock.
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response).unwrap();
|
||||||
|
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
|
||||||
|
let (_, body) = response.split_once("\r\n\r\n").unwrap();
|
||||||
|
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(manifest["version"], "999.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_tmp: tempfile::TempDir,
|
||||||
|
exe: PathBuf,
|
||||||
|
home: PathBuf,
|
||||||
|
binary: Vec<u8>,
|
||||||
|
server: ReleaseServer,
|
||||||
|
daemon: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
|
||||||
|
fs::create_dir(&dir).unwrap();
|
||||||
|
let exe = dir.join("bsk.exe");
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
fs::create_dir(&home).unwrap();
|
||||||
|
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
|
||||||
|
// A PE overlay distinguishes the replacement without requiring a
|
||||||
|
// second build or changing the executable's behavior/version.
|
||||||
|
let mut binary = fs::read(&exe).unwrap();
|
||||||
|
binary.extend_from_slice(MARKER);
|
||||||
|
let server = ReleaseServer::new(&binary);
|
||||||
|
Self {
|
||||||
|
_tmp: tmp,
|
||||||
|
exe,
|
||||||
|
home,
|
||||||
|
binary,
|
||||||
|
server,
|
||||||
|
daemon: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self) -> Command {
|
||||||
|
let mut cmd = Command::new(&self.exe);
|
||||||
|
cmd.env("BSK_HOME", &self.home)
|
||||||
|
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
|
||||||
|
.env("BSK_AUTO_UPDATE", "off")
|
||||||
|
.env("RUST_LOG", "info")
|
||||||
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
.env_remove("BSK_DAEMONIZED")
|
||||||
|
.env_remove("BSK_DAEMON_REPLACES_PID")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.creation_flags(0x0800_0000);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if check() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.path(),
|
||||||
|
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
|
||||||
|
.into_owned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let home_logs: Vec<_> = fs::read_dir(&self.home)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.path(),
|
||||||
|
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
|
||||||
|
.into_owned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
panic!(
|
||||||
|
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> Option<DaemonInfo> {
|
||||||
|
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn updated(&self) -> bool {
|
||||||
|
// A locked executable may briefly reject reads during replacement.
|
||||||
|
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
|
||||||
|
// the foreground process on assertion failures to avoid leaking locks.
|
||||||
|
let _ = self.command().args(["daemon", "stop"]).output();
|
||||||
|
if let Some(child) = self.daemon.as_mut() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_update_replaces_the_running_executable_after_cli_exit() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let out = fixture
|
||||||
|
.command()
|
||||||
|
.args(["--json", "update", "--yes"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||||
|
assert_eq!(report["status"], "staged");
|
||||||
|
fixture.wait_for("CLI replacement and helper cleanup", || {
|
||||||
|
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
fixture.info().is_none(),
|
||||||
|
"an update must not start a previously absent daemon"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
|
||||||
|
let mut fixture = Fixture::new();
|
||||||
|
let port = unused_port();
|
||||||
|
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
|
||||||
|
let child = fixture
|
||||||
|
.command()
|
||||||
|
.env("BSK_AUTO_UPDATE", "on")
|
||||||
|
// Exercise the environment inherited by a normally detached daemon.
|
||||||
|
.env("BSK_DAEMONIZED", "1")
|
||||||
|
.args([
|
||||||
|
"daemon",
|
||||||
|
"start",
|
||||||
|
"--port",
|
||||||
|
&port.to_string(),
|
||||||
|
"--session-idle",
|
||||||
|
"1234ms",
|
||||||
|
"--daemon-idle",
|
||||||
|
"30s",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::from(log.try_clone().unwrap()))
|
||||||
|
.stderr(Stdio::from(log))
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let old_pid = child.id();
|
||||||
|
fixture.daemon = Some(child);
|
||||||
|
fixture.wait_for("replacement daemon", || {
|
||||||
|
fixture.updated()
|
||||||
|
&& fixture
|
||||||
|
.info()
|
||||||
|
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.daemon
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.try_wait()
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"old daemon must exit"
|
||||||
|
);
|
||||||
|
fixture.wait_for("helper cleanup", || {
|
||||||
|
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
|
||||||
|
});
|
||||||
|
let status = fixture
|
||||||
|
.command()
|
||||||
|
.args(["--json", "status"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
status.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&status.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.server.requests.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"the replacement must not stage another update immediately"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unused_port() -> u16 {
|
||||||
|
TcpListener::bind("127.0.0.1:0")
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
.port()
|
||||||
|
}
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
# dsh-plugin-browserskill
|
# BrowserSkill for DeepSeek Harness
|
||||||
|
|
||||||
npm: [`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
|
[](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)
|
||||||
|
|
||||||
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) tool plugin that exposes
|
Use [BrowserSkill](https://github.com/Tencent/BrowserSkill) in
|
||||||
[BrowserSkill](https://github.com/Tencent/BrowserSkill) (`bsk`) browser automation to the model.
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) to browse
|
||||||
|
websites, fill forms, and capture screenshots through native `browser_*` tools.
|
||||||
|
Browser tasks run in Agent Windows, with a live view in the dsh Web UI.
|
||||||
|
|
||||||
Each action maps to one `bsk <cmd> --json` invocation: the plugin spawns the bsk CLI, parses its
|
## Installation
|
||||||
structured JSON output, and returns a canonical typed value. The bsk daemon, browser, and browser
|
|
||||||
extension keep owning the actual browser control — this package is a thin, well-typed bridge.
|
Before installing the plugin:
|
||||||
|
|
||||||
|
- Install [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) and
|
||||||
|
[pnpm](https://pnpm.io/installation), which dsh uses to manage plugins.
|
||||||
|
- Install the `bsk` CLI and connect the BrowserSkill extension in Chrome or Edge.
|
||||||
|
Follow the [BrowserSkill setup guide](https://github.com/Tencent/BrowserSkill#quick-start).
|
||||||
|
- Make sure `bsk` is on the `PATH` used to start dsh, or set `bskPath` in the plugin configuration.
|
||||||
|
|
||||||
|
Install the plugin into the `web` profile, then start it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||||
|
dsh --profile web
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `web` with your profile name if you use a different profile. The plugin
|
||||||
|
includes the `browser-skill` skill; no separate `bsk install-skill` step is needed.
|
||||||
|
|
||||||
|
In a conversation, try:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/browser-skill open example.com and summarize the page.
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the browser tools become available when the skill is invoked.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
Installed plugins do not update automatically. To upgrade this plugin to npm's
|
||||||
|
`latest` version, including versions outside the profile's saved dependency range:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web update @wxg-prc-cpg/browser-skill-dsh-plugin --latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart that dsh profile after upgrading. This command updates the plugin; update
|
||||||
|
the `bsk` CLI and browser extension separately when a release requires it.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
@@ -20,23 +58,7 @@ extension keep owning the actual browser control — this package is a thin, wel
|
|||||||
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
|
| `browser_tabs` | `list`, `create`, `select`, `close`, `borrow`, `return` | Manage Agent Window tabs and temporarily borrow user tabs. |
|
||||||
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
|
| `browser_assist` | `resize`, `emulate`, `request-help` | Resize or emulate the browser and pause for human-only steps. |
|
||||||
|
|
||||||
`bsk evaluate` and `bsk record` are intentionally not exposed by this plugin: arbitrary page
|
Arbitrary page-script evaluation and interaction recording are not supported.
|
||||||
evaluation is a higher-risk capability, while recording is long-running and needs a dedicated
|
|
||||||
DeepSeek Harness lifecycle before it can be added safely.
|
|
||||||
|
|
||||||
## Agent skill (progressive disclosure)
|
|
||||||
|
|
||||||
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
|
||||||
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
|
||||||
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
|
||||||
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
|
||||||
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
|
||||||
concatenated: its command examples belong to a different execution interface and would bypass
|
|
||||||
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
|
||||||
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
|
||||||
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
|
||||||
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
|
||||||
seam degrade silently.
|
|
||||||
|
|
||||||
## Multi-session model
|
## Multi-session model
|
||||||
|
|
||||||
@@ -55,136 +77,73 @@ an explicit `session` argument naming a foreign or unknown id is rejected, the `
|
|||||||
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
|
`browser_session` shows plugin-created sessions only (no daemon-wide view), and stop/unload cleanup
|
||||||
can never touch a session owned by another program.
|
can never touch a session owned by another program.
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
The plugin follows the standard dsh bundle layout (`dsh.bundle` manifest + `cordis.patch.yml`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dsh plugin --profile <name> add @wxg-prc-cpg/browser-skill-dsh-plugin
|
|
||||||
dsh --profile <name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Prerequisite: the `bsk` CLI must be installed and on `PATH`, and the BrowserSkill browser extension
|
|
||||||
(Chrome or Edge) must be connected — see the
|
|
||||||
[BrowserSkill README](https://github.com/Tencent/BrowserSkill). When bsk is missing, tool calls fail
|
|
||||||
with install guidance instead of a bare spawn error.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All fields are optional and validated through the plugin's Schemastery `Config`:
|
After installing the plugin, edit your profile's `cordis.patch.yml`. For the `web`
|
||||||
|
profile, the default location is `~/.dsh/profiles/web/cordis.patch.yml`. If you set
|
||||||
|
`DSH_HOME`, use `$DSH_HOME/profiles/web/cordis.patch.yml` instead. Replace `web` with
|
||||||
|
your profile name as needed.
|
||||||
|
|
||||||
|
If the file contains only comments and `[]`, keep the comments and replace `[]`
|
||||||
|
with the YAML below. If it already contains patch entries, add this entry to the
|
||||||
|
existing list or edit its existing `id: browserskill` entry. Keep a single
|
||||||
|
top-level YAML list. This overrides the plugin registered by the installed bundle:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# cordis.patch.yml override example
|
- id: browserskill
|
||||||
- insert:
|
config:
|
||||||
- id: browserskill
|
bskPath: bsk
|
||||||
name: "@wxg-prc-cpg/browser-skill-dsh-plugin"
|
defaultTimeoutMs: 120000
|
||||||
config:
|
maxSessions: 5
|
||||||
bskPath: bsk # path to the bsk binary (default: resolve from PATH)
|
observationEnabled: true
|
||||||
defaultTimeoutMs: 120000
|
thumbnailIntervalMs: 1500
|
||||||
maxSessions: 5
|
idleIntervalMs: 8000
|
||||||
# observationEnabled: true # live PiP/overlay observation (below)
|
lazyTools: true
|
||||||
# thumbnailIntervalMs: 1500 # frame cadence while a session is active
|
|
||||||
# idleIntervalMs: 8000 # idle cadence / recent-activity window
|
|
||||||
# lazyTools: true # reveal browser_* tools only after the skill is invoked
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`lazyTools` (default `true`)** — the final progressive-disclosure stage: the six
|
Change `bskPath` to the full path of your CLI binary if it is not on dsh's `PATH`.
|
||||||
`browser_*` tool schemas stay OUT of the system prompt (zero schema tokens) until the
|
A patch replaces the entry's entire `config` object, so keep all overrides you need
|
||||||
`browser-skill` skill is actually invoked — the skill catalog entry is the only
|
together in that object.
|
||||||
advertisement. One successful invocation (model tool call, or a `/browser-skill` user
|
|
||||||
gesture) registers the whole suite for the rest of the process; repeated invocations are
|
|
||||||
no-ops, and sessions resumed with a past invocation in their durable log reveal the suite
|
|
||||||
on entry. Set `false` for the legacy always-on registration.
|
|
||||||
|
|
||||||
## Observation overlay (PiP mini-window)
|
Configuration changes follow `dsh.profile.patchReload` in the profile's
|
||||||
|
`package.json`: `live` (the default for `web`) applies changes when you save the
|
||||||
|
patch file; `startup` requires restarting the profile. Restart after upgrading
|
||||||
|
the plugin in either case.
|
||||||
|
|
||||||
When the plugin runs inside the dsh Web UI, an **observation overlay** floats over the app
|
All fields are optional; omitted fields use the defaults below:
|
||||||
(registered into the `shell.overlay` seat): a breathing thumbnail per owned session plus its
|
|
||||||
current action and elapsed time. The card docks at the top-right of the content area (clear of
|
|
||||||
the composer and the shell's header controls) and wears the **BrowserSkill product family's own
|
|
||||||
look**: the overlay reuses `@browser-skill/ui` components (`Button`, `cn`) and its oklch design
|
|
||||||
tokens (`--card`, `--primary`, `--destructive`, `--ring`, …), status dots spec'd after the
|
|
||||||
extension popup's connection indicator, and Remix icons. The BSK utility sheet is compiled
|
|
||||||
scoped under the `.bsk-obs` root class (`scripts/build-client-css.mjs`), so the overlay looks
|
|
||||||
like the BrowserSkill extension without leaking a single selector into the host shell — and the
|
|
||||||
shell's theme cannot bleed back in.
|
|
||||||
|
|
||||||
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
|
| Option | Default | Purpose |
|
||||||
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
|
| --- | --- | --- |
|
||||||
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
|
| `bskPath` | `bsk` | Path to the CLI binary. |
|
||||||
frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
|
| `defaultTimeoutMs` | `120000` | Default command timeout in milliseconds. |
|
||||||
stage while the next one loads, and is kept on errors so the card does not flash),
|
| `maxSessions` | `5` | Maximum concurrent sessions started by this plugin. |
|
||||||
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
|
| `observationEnabled` | `true` | Enable live browser observation. |
|
||||||
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
|
| `thumbnailIntervalMs` | `1500` | Screenshot interval for active sessions, in milliseconds. |
|
||||||
as the chat Stop button — the current action fails, the agent run may continue). Strip items
|
| `idleIntervalMs` | `8000` | Screenshot interval for idle sessions and the recent-activity window, in milliseconds. |
|
||||||
carry their own hover interrupt button.
|
| `lazyTools` | `true` | Reveal the browser tools when the skill is invoked. Set `false` to register them at startup. |
|
||||||
- **Multi-session strip**: every session gets a tile (mini frame + id + status dot); focus
|
|
||||||
auto-follows the most recently active session; clicking a tile pins focus (pin badge, click
|
|
||||||
again to release); errored sessions get a red edge without stealing focus; sessions the daemon
|
|
||||||
lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys
|
|
||||||
the interrupt button until captures recover.
|
|
||||||
- **Drag & resize**: drag the header to move the card; drag any of the four
|
|
||||||
corners to resize (min 240×180, max 80% of the viewport; no visible grip).
|
|
||||||
Both are remembered for the page lifetime.
|
|
||||||
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
|
|
||||||
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
|
|
||||||
in-page card with state intact. Browsers without Document PiP simply hide the button.
|
|
||||||
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
|
|
||||||
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
|
|
||||||
keep subscribing to state without requesting screenshots. The first screenshot subscriber
|
|
||||||
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
|
|
||||||
An already running capture may finish. Demand applies to the service's owned sessions, not just
|
|
||||||
the selected session.
|
|
||||||
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
|
|
||||||
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
|
|
||||||
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
|
|
||||||
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
|
|
||||||
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
|
|
||||||
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
|
|
||||||
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
|
|
||||||
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
|
|
||||||
live clients should initialize and resynchronize from the stream's snapshot instead.
|
|
||||||
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
|
|
||||||
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
|
|
||||||
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
|
|
||||||
session — tool calls and frame captures alike — run through a per-session FIFO, because the
|
|
||||||
daemon accepts only one unfinished command per session.
|
|
||||||
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
|
|
||||||
subscribes to state without requesting screenshots. On an active service, the listener receives
|
|
||||||
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
|
|
||||||
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
|
|
||||||
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
|
|
||||||
demand. Subscribing after service disposal is a no-op.
|
|
||||||
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
|
||||||
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
|
||||||
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
|
||||||
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
|
|
||||||
`application/json` body (cross-site simple requests can never satisfy that). The channel is
|
|
||||||
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
|
|
||||||
reaching it through a LAN address will (deliberately) fail the fence; do not put these
|
|
||||||
routes behind a non-loopback reverse proxy without adding your own authentication.
|
|
||||||
- Configure with `observationEnabled` / `thumbnailIntervalMs` / `idleIntervalMs`.
|
|
||||||
|
|
||||||
## Behavior notes
|
With `lazyTools: true`, only the skill's catalog entry is initially advertised to
|
||||||
|
the model. The six `browser_*` tool schemas are added to the system prompt after
|
||||||
|
the `browser-skill` skill is successfully invoked, either by the model or through
|
||||||
|
`/browser-skill`. Set `lazyTools: false` to make the tools available immediately.
|
||||||
|
|
||||||
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
|
## Live browser view
|
||||||
matching BrowserSkill's cooperative tool-cancellation model.
|
|
||||||
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
|
The dsh Web UI shows the plugin's browser sessions in a floating panel. If your
|
||||||
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
profile provides the `dsh-better-sidebar` integration, the view appears in a
|
||||||
and the active model route declares image input; otherwise the PNG path is returned.
|
**Browser Skill** sidebar tab instead.
|
||||||
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
|
||||||
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
|
- See the current action, elapsed time, and recent screenshot for each session.
|
||||||
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
|
- Select a session to focus on it. The sidebar view follows the current conversation.
|
||||||
an image block, resolves the durable attachment through the client session's authorized
|
- Use **Interrupt** to cancel the current browser command. The agent may continue
|
||||||
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
with another action afterward.
|
||||||
The other five tools keep the stock terminal card. The bundle follows the dsh client
|
- Drag or resize the floating panel, or use **Pop out** to open a Picture-in-Picture
|
||||||
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
window in browsers that support it.
|
||||||
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
- Periodic screenshots are requested while a browser observation view is visible.
|
||||||
lightningcss.
|
Configure the active and idle intervals with the options above.
|
||||||
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
|
|
||||||
so the model gets the daemon's actionable guidance.
|
The observation endpoints require a loopback address such as `localhost` or
|
||||||
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
|
`127.0.0.1`. Access through a LAN hostname or non-loopback reverse proxy is not supported.
|
||||||
follow-up.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -195,22 +154,47 @@ pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin test # unit tests mock b
|
|||||||
pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
|
pnpm --filter @wxg-prc-cpg/browser-skill-dsh-plugin build # tsdown -> lib/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See the [development notes](https://github.com/Tencent/BrowserSkill/blob/main/packages/dsh-plugin-browserskill/docs/development.md)
|
||||||
|
for skill registration, tool results, and observation APIs in the current source.
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
The GitHub Actions workflow **Release dsh plugin** publishes this package to npm
|
The [Release dsh plugin workflow](https://github.com/Tencent/BrowserSkill/blob/main/.github/workflows/release-dsh-plugin.yml)
|
||||||
as `@wxg-prc-cpg/browser-skill-dsh-plugin`. The Cordis plugin id, client
|
publishes the package and this README to npm. Pushing a `dsh-plugin-vX.Y.Z` tag
|
||||||
bundle registration, and `cordis.patch.yml` name are the same specifier so
|
triggers it; ordinary commits to `main` do not.
|
||||||
dsh can import and materialize the plugin without a ModuleLoader id mismatch.
|
|
||||||
|
|
||||||
Trigger it by pushing a tag that matches `package.json`'s `version`:
|
1. Commit this README and any other changes intended for the release.
|
||||||
|
2. From the repository root, run the
|
||||||
|
[release script](https://github.com/Tencent/BrowserSkill/blob/main/scripts/release.mjs)
|
||||||
|
with a new stable version, replacing `<version>` below. The script updates the
|
||||||
|
CLI, extension, and DSH plugin versions, commits the version changes, and creates
|
||||||
|
their release tags:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag dsh-plugin-v0.1.2
|
node scripts/release.mjs <version>
|
||||||
git push origin dsh-plugin-v0.1.2
|
```
|
||||||
```
|
|
||||||
|
|
||||||
Or run the workflow from the Actions tab (`workflow_dispatch`). The job reads the
|
3. Push the version commit to the release branch, then push the DSH plugin tag
|
||||||
`NPM_TOKEN` secret from the `npm-publish` GitHub Environment.
|
created by the script, using the same `<version>`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin dsh-plugin-v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
This publishes the DSH plugin. Push the CLI and extension tags separately when
|
||||||
|
those components are ready for release.
|
||||||
|
|
||||||
|
The workflow checks the version, runs typechecks and tests, builds the package,
|
||||||
|
and publishes it to npm.
|
||||||
|
|
||||||
|
You can also run the workflow manually from GitHub Actions on the intended release
|
||||||
|
ref. Both triggers require an unpublished version and the `NPM_TOKEN` secret in the
|
||||||
|
`npm-publish` GitHub Environment.
|
||||||
|
|
||||||
|
npm updates the package README only when a new version is published, including
|
||||||
|
for documentation-only changes. Published versions cannot be overwritten. See
|
||||||
|
[npm's README update rules](https://docs.npmjs.com/about-package-readme-files/).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# DSH plugin development notes
|
||||||
|
|
||||||
|
For installation, usage, and configuration, see the [plugin README](../README.md).
|
||||||
|
These notes describe the current source on this branch; a published npm version may
|
||||||
|
not include later changes.
|
||||||
|
|
||||||
|
## Tool execution
|
||||||
|
|
||||||
|
The plugin runs the `bsk` CLI and parses its JSON output. The daemon and browser
|
||||||
|
extension perform the browser operations. The published package name is also the
|
||||||
|
Cordis plugin id, client bundle registration id, and `cordis.patch.yml` plugin name.
|
||||||
|
Keep these identifiers aligned so dsh can load both halves of the plugin.
|
||||||
|
|
||||||
|
## Agent skill
|
||||||
|
|
||||||
|
Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's
|
||||||
|
official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is
|
||||||
|
resident in `<available_skills>`, and the body is loaded only when the model invokes the `skill`
|
||||||
|
tool. Its single source is the DSH-specific `skill/SKILL.md`, which documents only structured
|
||||||
|
`browser_*` calls and their plugin semantics. The repository-root CLI skill is intentionally not
|
||||||
|
concatenated: its command examples belong to a different execution interface and would bypass
|
||||||
|
the plugin's ownership, live observation UI, cancellation, and cleanup path if followed directly.
|
||||||
|
The build rejects internal CLI-name leakage, command-line code blocks, unknown browser tools, and
|
||||||
|
missing supported browser tools before embedding the Markdown. Registration and every pre-step
|
||||||
|
catalog snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill
|
||||||
|
seam degrade silently.
|
||||||
|
|
||||||
|
With `lazyTools: true`, the six tool schemas stay out of the system prompt until
|
||||||
|
the skill is successfully invoked. Registration happens once and lasts until the
|
||||||
|
plugin is unloaded. Repeated invocations do not register duplicate tools. Entering
|
||||||
|
a resumed conversation whose history contains a successful skill invocation also
|
||||||
|
registers the tools. Setting `lazyTools: false` registers them at plugin startup.
|
||||||
|
|
||||||
|
## Observation subscriptions and routes
|
||||||
|
|
||||||
|
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
|
||||||
|
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
|
||||||
|
keep subscribing to state without requesting screenshots. The first screenshot subscriber
|
||||||
|
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
|
||||||
|
An already running capture may finish. Demand applies to the service's owned sessions, not just
|
||||||
|
the selected session.
|
||||||
|
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
|
||||||
|
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
|
||||||
|
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
|
||||||
|
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
|
||||||
|
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
|
||||||
|
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
|
||||||
|
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
|
||||||
|
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
|
||||||
|
live clients should initialize and resynchronize from the stream's snapshot instead.
|
||||||
|
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
|
||||||
|
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
|
||||||
|
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
|
||||||
|
session — tool calls and frame captures alike — run through a per-session FIFO, because the
|
||||||
|
daemon accepts only one unfinished command per session.
|
||||||
|
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
|
||||||
|
subscribes to state without requesting screenshots. On an active service, the listener receives
|
||||||
|
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
|
||||||
|
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
|
||||||
|
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
|
||||||
|
demand. Subscribing after service disposal is a no-op.
|
||||||
|
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
|
||||||
|
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
|
||||||
|
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
|
||||||
|
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
|
||||||
|
`application/json` body (cross-site simple requests can never satisfy that). The channel is
|
||||||
|
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
|
||||||
|
reaching it through a LAN address will (deliberately) fail the fence; do not put these
|
||||||
|
routes behind a non-loopback reverse proxy without adding your own authentication.
|
||||||
|
|
||||||
|
## Tool results and cancellation
|
||||||
|
|
||||||
|
- **Cancellation**: aborting a tool call (`exec.signal`) kills the underlying bsk child process,
|
||||||
|
matching BrowserSkill's cooperative tool-cancellation model.
|
||||||
|
- **UI cards**: calls render as terminal cards (command line as title, output as the completed
|
||||||
|
card). Screenshots additionally attach the image itself when the host mounts an attachment store
|
||||||
|
and the active model route declares image input; otherwise the PNG path is returned.
|
||||||
|
- **Web UI toolview (browser half)**: the package is dual-face. `dsh.client` (platform `web`) ships
|
||||||
|
`lib/client.cjs`, which registers a keyed `tool.call.toolview` view for `browser_inspect`. The
|
||||||
|
custom view keeps a terminal block for every inspect action and, when a screenshot result carries
|
||||||
|
an image block, resolves the durable attachment through the client session's authorized
|
||||||
|
`readAttachment` RPC and renders it with the shared `MessageImage` thumbnail/lightbox atoms.
|
||||||
|
The other five tools keep the stock terminal card. The bundle follows the dsh client
|
||||||
|
contract: a CJS closure factory handed to `window.__ModuleLoader__.load`, platform modules
|
||||||
|
(`react`, `dsh-client-ui-*`) external, everything else inlined, CSS Modules compiled by
|
||||||
|
lightningcss.
|
||||||
|
- **Errors**: non-zero bsk exits surface the CLI's JSON error envelope (`code`, `message`, `hint`)
|
||||||
|
so the model gets the daemon's actionable guidance.
|
||||||
|
- **Long-running work** (e.g. `bsk record`) is not backgrounded via `ctx.jobs` yet — tracked as a
|
||||||
|
follow-up.
|
||||||
@@ -47,7 +47,6 @@
|
|||||||
"client": {
|
"client": {
|
||||||
"platform": "web",
|
"platform": "web",
|
||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-runtime",
|
|
||||||
"@deepseek-ai/dsh-client-ui-tool",
|
"@deepseek-ai/dsh-client-ui-tool",
|
||||||
"@deepseek-ai/dsh-client-ui-layout"
|
"@deepseek-ai/dsh-client-ui-layout"
|
||||||
],
|
],
|
||||||
@@ -69,7 +68,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/cordis": "^4.0.1",
|
"@deepseek-ai/cordis": "^4.0.1",
|
||||||
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
||||||
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
||||||
@@ -82,9 +80,6 @@
|
|||||||
"@deepseek-ai/dsh-attachment": {
|
"@deepseek-ai/dsh-attachment": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@deepseek-ai/dsh-client-runtime": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@deepseek-ai/dsh-client-ui-attachment": {
|
"@deepseek-ai/dsh-client-ui-attachment": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||||
|
// Development-only types for the injected services; newer hosts no longer ship this package.
|
||||||
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
|
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
|
||||||
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
|
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
|
||||||
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
|
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const CLIENT_EXTERNALS: readonly string[] = [
|
|||||||
"@deepseek-ai/dsh-client-ui-primitives",
|
"@deepseek-ai/dsh-client-ui-primitives",
|
||||||
"@deepseek-ai/dsh-client-ui-attachment",
|
"@deepseek-ai/dsh-client-ui-attachment",
|
||||||
"@deepseek-ai/dsh-client-schema-form",
|
"@deepseek-ai/dsh-client-schema-form",
|
||||||
"@deepseek-ai/dsh-client-runtime/client",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Wire/type layers with no shared runtime identity: safe to inline. */
|
/** Wire/type layers with no shared runtime identity: safe to inline. */
|
||||||
|
|||||||
Reference in New Issue
Block a user