mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b0ff301b | ||
|
|
e74f354e90 | ||
|
|
05c036518a | ||
|
|
020fb3212e | ||
|
|
0822c53c65 | ||
|
|
4450fbe28c | ||
|
|
a3bb81bfab | ||
|
|
caea98cd52 | ||
|
|
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, installer and cancellation regression tests
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -52,6 +52,20 @@ 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 and cancellation process tests
|
||||||
|
run: cargo test -p bsk --test windows_update --test windows_parent_cancel --locked
|
||||||
|
|
||||||
|
- name: Run installer tests (Windows PowerShell 5.1)
|
||||||
|
shell: powershell
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
|
- name: Run installer tests (PowerShell 7)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+18
-1
@@ -5,7 +5,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
|
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.2.1] - 2026-09-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- DSH Plugin: clarified installation, configuration, profile restart, and upgrade instructions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CLI: reliable Windows self-updates, including executable replacement and daemon restart
|
||||||
|
- CLI: Windows named-pipe connection continuity and stale daemon process detection
|
||||||
|
- Windows installer: PowerShell 5.1 compatibility, path quoting, and staged executable replacement
|
||||||
|
- CLI / DSH Plugin: cancellation propagation and upload/download cleanup when the parent process exits
|
||||||
|
- Extension: tab ownership and session cleanup when agent tabs fail to open or borrowed tabs are returned or closed
|
||||||
|
- Extension: navigation recovery when other extensions restrict Chrome DevTools Protocol access
|
||||||
|
- Extension: background input filling, automatic append behavior, and verification of field values before reporting success
|
||||||
|
- Extension: match observed form state to the correct element after page changes
|
||||||
|
- Extension: UUID generation fallback for recording and overlays on insecure origins
|
||||||
|
- Browser locale matching for regional language variants
|
||||||
|
- DSH Plugin: observation state synchronization, thumbnail capture lifecycle, and stale preview cleanup
|
||||||
|
- DSH Plugin: removed the obsolete client runtime host dependency
|
||||||
|
|
||||||
## [0.2.0] - 2026-09-02
|
## [0.2.0] - 2026-09-02
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk"
|
name = "bsk"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -169,7 +169,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk-protocol"
|
name = "bsk-protocol"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"semver",
|
"semver",
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ members = [
|
|||||||
default-members = ["crates/bsk-cli"]
|
default-members = ["crates/bsk-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -43,7 +43,7 @@ rand = "0.8"
|
|||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
||||||
# Workspace crates (version required for `cargo publish` of dependents)
|
# Workspace crates (version required for `cargo publish` of dependents)
|
||||||
bsk-protocol = { version = "0.2.0", path = "crates/bsk-protocol" }
|
bsk-protocol = { version = "0.2.1", path = "crates/bsk-protocol" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@browser-skill/extension",
|
"name": "@browser-skill/extension",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ where
|
|||||||
P: Serialize + Send + 'static,
|
P: Serialize + Send + 'static,
|
||||||
R: DeserializeOwned + Send + 'static,
|
R: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
|
let parent_cancel = stdin_cancellation();
|
||||||
|
if parent_cancel.as_ref().is_some_and(|rx| *rx.borrow()) {
|
||||||
|
return Err(CliError::from_rpc(RpcError {
|
||||||
|
code: ErrorCode::Cancelled,
|
||||||
|
message: "parent closed the cancellation pipe".into(),
|
||||||
|
data: Some(serde_json::json!({"reason": "cancelled_before_dispatch"})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
|
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
|
||||||
let mut client = IpcClient::connect(sock.clone()).await?;
|
let mut client = IpcClient::connect(sock.clone()).await?;
|
||||||
let rpc_id_for_call = rpc_id.clone();
|
let rpc_id_for_call = rpc_id.clone();
|
||||||
@@ -88,16 +96,16 @@ where
|
|||||||
let outcome = tokio::select! {
|
let outcome = tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
res = &mut main_fut => res?,
|
res = &mut main_fut => res?,
|
||||||
sig = wait_for_sigint() => {
|
sig = wait_for_cancel(parent_cancel) => {
|
||||||
sig.context("install SIGINT handler").map_err(CliError::Local)?;
|
sig.context("wait for cancellation").map_err(CliError::Local)?;
|
||||||
debug!(rpc_id = %rpc_id_for_cancel, "SIGINT: forwarding cancel to daemon");
|
debug!(rpc_id = %rpc_id_for_cancel, "forwarding cancel to daemon");
|
||||||
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
|
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
|
||||||
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
|
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
|
||||||
Ok(res) => res?,
|
Ok(res) => res?,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
debug!(
|
debug!(
|
||||||
rpc_id = %rpc_id_for_cancel,
|
rpc_id = %rpc_id_for_cancel,
|
||||||
"SIGINT: cancel grace elapsed; synthesising cancelled error"
|
"cancel grace elapsed; synthesising cancelled error"
|
||||||
);
|
);
|
||||||
return Err(CliError::from_rpc(RpcError {
|
return Err(CliError::from_rpc(RpcError {
|
||||||
code: ErrorCode::Cancelled,
|
code: ErrorCode::Cancelled,
|
||||||
@@ -112,6 +120,46 @@ where
|
|||||||
outcome.map_err(CliError::from_rpc)
|
outcome.map_err(CliError::from_rpc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release caller-owned staging even after cancellation. This deliberately
|
||||||
|
/// exposes only transfer.release, not a general bypass for business RPCs.
|
||||||
|
/// The entire batch, including connection setup, shares one bounded budget.
|
||||||
|
pub fn release_transfers<'a>(
|
||||||
|
sock: &Path,
|
||||||
|
transfer_ids: impl IntoIterator<Item = &'a str>,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
use bsk_protocol::tools::{TransferIdParams, TransferReleaseResult};
|
||||||
|
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
let ids: Vec<_> = transfer_ids.into_iter().collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("build runtime for transfer cleanup")?;
|
||||||
|
rt.block_on(async {
|
||||||
|
tokio::time::timeout(CLEANUP_TIMEOUT, async {
|
||||||
|
let mut client = IpcClient::connect(sock).await?;
|
||||||
|
for id in ids {
|
||||||
|
client
|
||||||
|
.call::<_, TransferReleaseResult>(
|
||||||
|
"transfer-release",
|
||||||
|
Method::TransferRelease,
|
||||||
|
Some(TransferIdParams {
|
||||||
|
transfer_id: id.to_string(),
|
||||||
|
}),
|
||||||
|
CLEANUP_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.map_err(CliError::from_rpc)?;
|
||||||
|
}
|
||||||
|
Ok::<_, CliError>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("transfer cleanup exceeded its total time budget")?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
|
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
|
||||||
/// lands on the daemon while the original call is still parked.
|
/// lands on the daemon while the original call is still parked.
|
||||||
///
|
///
|
||||||
@@ -146,8 +194,57 @@ fn random_short_id() -> String {
|
|||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for either Ctrl-C or process SIGTERM. Returning here means
|
/// Opt-in control pipe for parent processes that cannot send Ctrl-C (Node on
|
||||||
/// the user has asked us to stop.
|
/// Windows). One reader survives the short-lived runtimes used by multi-RPC
|
||||||
|
/// commands such as upload. Ordinary CLI stdin and terminal Ctrl-C are unchanged.
|
||||||
|
fn stdin_cancellation() -> Option<tokio::sync::watch::Receiver<bool>> {
|
||||||
|
if std::env::var("BSK_CANCEL_ON_STDIN_CLOSE").as_deref() != Ok("1") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
static CANCEL: std::sync::OnceLock<tokio::sync::watch::Receiver<bool>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
Some(
|
||||||
|
CANCEL
|
||||||
|
.get_or_init(|| {
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut stdin = std::io::stdin().lock();
|
||||||
|
let mut buf = [0; 64];
|
||||||
|
loop {
|
||||||
|
match stdin.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(true);
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
})
|
||||||
|
.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_cancel(
|
||||||
|
parent_cancel: Option<tokio::sync::watch::Receiver<bool>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let parent_closed = async move {
|
||||||
|
match parent_cancel {
|
||||||
|
Some(mut rx) => {
|
||||||
|
let _ = rx.wait_for(|closed| *closed).await;
|
||||||
|
}
|
||||||
|
None => std::future::pending::<()>().await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
result = wait_for_sigint() => result,
|
||||||
|
_ = parent_closed => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for terminal Ctrl-C.
|
||||||
async fn wait_for_sigint() -> anyhow::Result<()> {
|
async fn wait_for_sigint() -> anyhow::Result<()> {
|
||||||
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
|
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ use anyhow::Context;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use bsk_protocol::Method;
|
use bsk_protocol::Method;
|
||||||
use bsk_protocol::tools::{
|
use bsk_protocol::tools::{
|
||||||
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult, TransferIdParams,
|
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult,
|
||||||
TransferReleaseResult,
|
|
||||||
};
|
};
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -70,13 +69,7 @@ pub fn dispatch(args: DownloadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
|
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
|
||||||
})?;
|
})?;
|
||||||
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
|
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
|
||||||
let _: Result<TransferReleaseResult, CliError> = crate::cli::business_rpc::call(
|
let _ = crate::cli::business_rpc::release_transfers(&info.sock_path, [transfer_id.as_str()]);
|
||||||
info.sock_path,
|
|
||||||
"transfer-release",
|
|
||||||
Method::TransferRelease,
|
|
||||||
Some(TransferIdParams { transfer_id }),
|
|
||||||
Duration::from_secs(5),
|
|
||||||
);
|
|
||||||
write_result?;
|
write_result?;
|
||||||
match format {
|
match format {
|
||||||
Format::Json => {
|
Format::Json => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -10,8 +10,7 @@ use base64::Engine;
|
|||||||
use bsk_protocol::Method;
|
use bsk_protocol::Method;
|
||||||
use bsk_protocol::tools::{
|
use bsk_protocol::tools::{
|
||||||
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
|
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
|
||||||
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode,
|
TransferIdParams, TransferReadyResult, UploadFile, UploadMode, UploadParams, UploadResult,
|
||||||
UploadParams, UploadResult,
|
|
||||||
};
|
};
|
||||||
use clap::{Args, ValueEnum};
|
use clap::{Args, ValueEnum};
|
||||||
|
|
||||||
@@ -69,14 +68,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
|
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
|
||||||
let mut staged = Vec::new();
|
let mut staged = Vec::new();
|
||||||
for path in &args.files {
|
for path in &args.files {
|
||||||
match stage_file(&info.sock_path, &args.session, path) {
|
if let Err(err) = stage_file(&info.sock_path, &args.session, path, &mut staged) {
|
||||||
Ok(file) => staged.push(file),
|
let _ = crate::cli::business_rpc::release_transfers(
|
||||||
Err(err) => {
|
&info.sock_path,
|
||||||
for (id, _) in &staged {
|
staged.iter().map(|(id, _)| id.as_str()),
|
||||||
let _ = release(&info.sock_path, id);
|
);
|
||||||
}
|
return Err(err);
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let params = UploadParams {
|
let params = UploadParams {
|
||||||
@@ -102,6 +99,17 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
Some(params),
|
Some(params),
|
||||||
ipc_timeout(args.timeout),
|
ipc_timeout(args.timeout),
|
||||||
);
|
);
|
||||||
|
// The helper can reject cancellation before handing any request to the
|
||||||
|
// daemon. In that case staging still belongs to this CLI.
|
||||||
|
if result.as_ref().is_err_and(|err| {
|
||||||
|
err.data()
|
||||||
|
.is_some_and(|data| data["reason"] == "cancelled_before_dispatch")
|
||||||
|
}) {
|
||||||
|
let _ = crate::cli::business_rpc::release_transfers(
|
||||||
|
&info.sock_path,
|
||||||
|
staged.iter().map(|(id, _)| id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Once tool.upload is dispatched, staging ownership belongs to the
|
// Once tool.upload is dispatched, staging ownership belongs to the
|
||||||
// session. A transport timeout cannot prove that Chrome did not attach
|
// session. A transport timeout cannot prove that Chrome did not attach
|
||||||
// the file, so releasing here could invalidate a late successful attach.
|
// the file, so releasing here could invalidate a late successful attach.
|
||||||
@@ -118,7 +126,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, String), CliError> {
|
fn stage_file(
|
||||||
|
sock: &Path,
|
||||||
|
session: &str,
|
||||||
|
path: &PathBuf,
|
||||||
|
staged: &mut Vec<(String, String)>,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
let mut file = File::open(path)
|
let mut file = File::open(path)
|
||||||
.with_context(|| format!("open upload file {}", path.display()))
|
.with_context(|| format!("open upload file {}", path.display()))
|
||||||
.map_err(CliError::Local)?;
|
.map_err(CliError::Local)?;
|
||||||
@@ -149,55 +162,39 @@ fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, Str
|
|||||||
}),
|
}),
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
)?;
|
)?;
|
||||||
let staged = (|| {
|
// Record ownership immediately so one error path releases both completed
|
||||||
let mut offset = 0u64;
|
// files and the current partially staged file.
|
||||||
let mut buf = vec![0u8; begin.chunk_size as usize];
|
staged.push((begin.transfer_id.clone(), name));
|
||||||
loop {
|
let mut offset = 0u64;
|
||||||
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
let mut buf = vec![0u8; begin.chunk_size as usize];
|
||||||
if n == 0 {
|
loop {
|
||||||
break;
|
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
||||||
}
|
if n == 0 {
|
||||||
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
break;
|
||||||
sock.to_path_buf(),
|
|
||||||
"transfer-chunk",
|
|
||||||
Method::TransferChunk,
|
|
||||||
Some(TransferChunkParams {
|
|
||||||
transfer_id: begin.transfer_id.clone(),
|
|
||||||
offset,
|
|
||||||
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
|
||||||
}),
|
|
||||||
Duration::from_secs(30),
|
|
||||||
)?;
|
|
||||||
offset = reply.next_offset;
|
|
||||||
}
|
}
|
||||||
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
||||||
sock.to_path_buf(),
|
sock.to_path_buf(),
|
||||||
"transfer-finish",
|
"transfer-chunk",
|
||||||
Method::TransferFinish,
|
Method::TransferChunk,
|
||||||
Some(TransferIdParams {
|
Some(TransferChunkParams {
|
||||||
transfer_id: begin.transfer_id.clone(),
|
transfer_id: begin.transfer_id.clone(),
|
||||||
|
offset,
|
||||||
|
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(30),
|
||||||
)?;
|
)?;
|
||||||
Ok::<_, CliError>(())
|
offset = reply.next_offset;
|
||||||
})();
|
|
||||||
if let Err(err) = staged {
|
|
||||||
let _ = release(sock, &begin.transfer_id);
|
|
||||||
return Err(err);
|
|
||||||
}
|
}
|
||||||
Ok((begin.transfer_id, name))
|
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
||||||
}
|
|
||||||
|
|
||||||
fn release(sock: &Path, id: &str) -> Result<TransferReleaseResult, CliError> {
|
|
||||||
crate::cli::business_rpc::call(
|
|
||||||
sock.to_path_buf(),
|
sock.to_path_buf(),
|
||||||
"transfer-release",
|
"transfer-finish",
|
||||||
Method::TransferRelease,
|
Method::TransferFinish,
|
||||||
Some(TransferIdParams {
|
Some(TransferIdParams {
|
||||||
transfer_id: id.to_string(),
|
transfer_id: begin.transfer_id.clone(),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(10),
|
||||||
)
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipc_timeout(timeout_ms: u32) -> Duration {
|
fn ipc_timeout(timeout_ms: u32) -> Duration {
|
||||||
|
|||||||
@@ -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,342 @@
|
|||||||
|
//! Real Windows CLI cancellation, including caller-owned transfer cleanup.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bsk::daemon::{file_transfer::TransferRegistry, info::DaemonInfo};
|
||||||
|
use bsk_protocol::tools::{TransferBeginParams, TransferIdParams};
|
||||||
|
use bsk_protocol::{ErrorCode, Frame, Method, ResponseBody, ResponseFrame, RpcError};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::windows::named_pipe::ServerOptions;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::{Notify, watch};
|
||||||
|
|
||||||
|
fn response<T: serde::Serialize>(result: Result<T, RpcError>) -> ResponseBody {
|
||||||
|
match result {
|
||||||
|
Ok(value) => ResponseBody::Ok(serde_json::to_value(value).unwrap()),
|
||||||
|
Err(err) => ResponseBody::Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_close_cancels_business_but_releases_resources_with_a_bounded_budget() {
|
||||||
|
// Resolve the production registry's root before starting any runtime threads.
|
||||||
|
let registry_home = tempfile::tempdir().unwrap();
|
||||||
|
let original_home = std::env::var_os("BSK_HOME");
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("BSK_HOME", registry_home.path());
|
||||||
|
}
|
||||||
|
let transfers = Arc::new(TransferRegistry::new().unwrap());
|
||||||
|
unsafe {
|
||||||
|
match original_home {
|
||||||
|
Some(value) => std::env::set_var("BSK_HOME", value),
|
||||||
|
None => std::env::remove_var("BSK_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transfers.initialize().unwrap();
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(exercise(
|
||||||
|
transfers,
|
||||||
|
®istry_home.path().join("run/transfers"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exercise(transfers: Arc<TransferRegistry>, staging_root: &Path) {
|
||||||
|
for mode in [
|
||||||
|
"wait",
|
||||||
|
"upload",
|
||||||
|
"upload-multi",
|
||||||
|
"upload-before-dispatch",
|
||||||
|
"download",
|
||||||
|
"upload-multi-stall",
|
||||||
|
"ordinary",
|
||||||
|
] {
|
||||||
|
let home = tempfile::tempdir().unwrap();
|
||||||
|
let pipe_name = format!(r"\\.\pipe\bsk-parent-cancel-{}", uuid::Uuid::new_v4());
|
||||||
|
let info = DaemonInfo::now(std::process::id(), (&pipe_name).into(), 0, "0.2.0");
|
||||||
|
std::fs::write(
|
||||||
|
home.path().join("daemon.json"),
|
||||||
|
serde_json::to_vec(&info).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut pipe = ServerOptions::new()
|
||||||
|
.first_pipe_instance(true)
|
||||||
|
.create(&pipe_name)
|
||||||
|
.unwrap();
|
||||||
|
let registered = Arc::new(Notify::new());
|
||||||
|
let pending = Arc::new(Mutex::new(None::<String>));
|
||||||
|
let ids = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let released = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let received = Arc::new(Mutex::new(Vec::<Method>::new()));
|
||||||
|
let download_id = if mode == "download" {
|
||||||
|
let staging = transfers.begin_download("fixture").unwrap();
|
||||||
|
let browser_dir = home.path().join("BrowserSkill").join(&staging.transfer_id);
|
||||||
|
std::fs::create_dir_all(&browser_dir).unwrap();
|
||||||
|
let file = browser_dir.join("download.txt");
|
||||||
|
std::fs::write(&file, b"download fixture").unwrap();
|
||||||
|
transfers
|
||||||
|
.import_download(&staging.transfer_id, &file)
|
||||||
|
.unwrap();
|
||||||
|
ids.lock().unwrap().push(staging.transfer_id.clone());
|
||||||
|
Some(staging.transfer_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (cancel, cancelled) = watch::channel(false);
|
||||||
|
let ready = Arc::clone(®istered);
|
||||||
|
let name = pipe_name.clone();
|
||||||
|
let registry = Arc::clone(&transfers);
|
||||||
|
let server_ids = Arc::clone(&ids);
|
||||||
|
let server_released = Arc::clone(&released);
|
||||||
|
let server_received = Arc::clone(&received);
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let mut connections = tokio::task::JoinSet::new();
|
||||||
|
loop {
|
||||||
|
pipe.connect().await.unwrap();
|
||||||
|
let next = ServerOptions::new().create(&name).unwrap();
|
||||||
|
let connection = std::mem::replace(&mut pipe, next);
|
||||||
|
let ready = Arc::clone(&ready);
|
||||||
|
let pending = Arc::clone(&pending);
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
let mut cancelled = cancelled.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
let ids = Arc::clone(&server_ids);
|
||||||
|
let released = Arc::clone(&server_released);
|
||||||
|
let received = Arc::clone(&server_received);
|
||||||
|
let download_id = download_id.clone();
|
||||||
|
connections.spawn(async move {
|
||||||
|
let (read, mut write) = tokio::io::split(connection);
|
||||||
|
let mut reader = BufReader::new(read);
|
||||||
|
let mut line = String::new();
|
||||||
|
while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
|
||||||
|
let Frame::Request(req) = serde_json::from_str::<Frame>(&line).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected request");
|
||||||
|
};
|
||||||
|
line.clear();
|
||||||
|
received.lock().unwrap().push(req.method.clone());
|
||||||
|
let params = req.params.clone().unwrap_or_default();
|
||||||
|
let body = match req.method {
|
||||||
|
Method::SystemStatus => ResponseBody::Ok(json!({
|
||||||
|
"daemon_version": "0.2.0", "protocol_version": "1.1",
|
||||||
|
"pid": std::process::id(), "uptime_secs": 0,
|
||||||
|
"ws_port": 0, "sock_path": name,
|
||||||
|
"browsers": [], "sessions": [], "version_skew_browsers": []
|
||||||
|
})),
|
||||||
|
Method::TransferBegin => {
|
||||||
|
let begin = registry
|
||||||
|
.begin_upload(serde_json::from_value(params).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
ids.lock().unwrap().push(begin.transfer_id.clone());
|
||||||
|
response(Ok(begin))
|
||||||
|
}
|
||||||
|
Method::ToolDownload => ResponseBody::Ok(json!({
|
||||||
|
"tab_id": 1, "suggested_filename": "download.txt",
|
||||||
|
"byte_size": 16, "transfer_id": download_id,
|
||||||
|
})),
|
||||||
|
Method::ToolWaitMs if mode == "ordinary" => {
|
||||||
|
ResponseBody::Ok(json!({"waited_ms": 1}))
|
||||||
|
}
|
||||||
|
Method::TransferChunk
|
||||||
|
if mode == "upload-before-dispatch"
|
||||||
|
|| (mode.contains("multi")
|
||||||
|
&& ids.lock().unwrap().len() == 1) =>
|
||||||
|
{
|
||||||
|
response(
|
||||||
|
registry.write_chunk(serde_json::from_value(params).unwrap()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Method::TransferFinish if mode != "upload-before-dispatch" => response(
|
||||||
|
registry.finish_upload(serde_json::from_value(params).unwrap()),
|
||||||
|
),
|
||||||
|
Method::ToolWaitMs
|
||||||
|
| Method::TransferChunk
|
||||||
|
| Method::TransferRead
|
||||||
|
| Method::TransferFinish => {
|
||||||
|
*pending.lock().unwrap() = Some(req.id.clone());
|
||||||
|
ready.notify_one();
|
||||||
|
cancelled.wait_for(|value| *value).await.unwrap();
|
||||||
|
if mode == "upload-before-dispatch" {
|
||||||
|
// Finish won the race with cancellation, but tool.upload must
|
||||||
|
// not be dispatched and the CLI still owns the staging.
|
||||||
|
response(
|
||||||
|
registry
|
||||||
|
.finish_upload(serde_json::from_value(params).unwrap()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ResponseBody::Err(RpcError {
|
||||||
|
code: ErrorCode::Cancelled,
|
||||||
|
message: "parent cancellation reached daemon".into(),
|
||||||
|
data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Method::Cancel => {
|
||||||
|
let target = params["rpc_id"].as_str().unwrap();
|
||||||
|
assert_eq!(pending.lock().unwrap().as_deref(), Some(target));
|
||||||
|
cancel.send(true).unwrap();
|
||||||
|
ResponseBody::Ok(json!({"cancelled": true}))
|
||||||
|
}
|
||||||
|
Method::TransferRelease => {
|
||||||
|
let params: TransferIdParams =
|
||||||
|
serde_json::from_value(params).unwrap();
|
||||||
|
released.lock().unwrap().push(params.transfer_id.clone());
|
||||||
|
if mode.contains("stall") {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
response(Ok(registry.release(params)))
|
||||||
|
}
|
||||||
|
other => panic!("unexpected request after cancellation: {other:?}"),
|
||||||
|
};
|
||||||
|
let response = Frame::Response(ResponseFrame { id: req.id, body });
|
||||||
|
let mut bytes = serde_json::to_vec(&response).unwrap();
|
||||||
|
bytes.push(b'\n');
|
||||||
|
if write.write_all(&bytes).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsk"));
|
||||||
|
cmd.env("BSK_HOME", home.path())
|
||||||
|
.env("BSK_AUTO_UPDATE", "off")
|
||||||
|
.env_remove("BSK_CANCEL_ON_STDIN_CLOSE")
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.creation_flags(0x0800_0000)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
if mode.starts_with("upload") {
|
||||||
|
let source = home.path().join("upload.txt");
|
||||||
|
// Declare 300 MiB without allocating or transferring that much data.
|
||||||
|
std::fs::File::create(&source)
|
||||||
|
.unwrap()
|
||||||
|
.set_len(if mode == "upload-before-dispatch" {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
300 * 1024 * 1024
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
cmd.args(["upload", "--session", "fixture", "--selector", "input"]);
|
||||||
|
if mode.contains("multi") {
|
||||||
|
let first = home.path().join("first.txt");
|
||||||
|
std::fs::write(&first, b"first file").unwrap();
|
||||||
|
cmd.arg("--file").arg(first);
|
||||||
|
}
|
||||||
|
cmd.arg("--file").arg(source);
|
||||||
|
} else if mode == "download" {
|
||||||
|
let out = home.path().join("out.txt");
|
||||||
|
std::fs::write(&out, b"existing output").unwrap();
|
||||||
|
cmd.args([
|
||||||
|
"download",
|
||||||
|
"--session",
|
||||||
|
"fixture",
|
||||||
|
"--selector",
|
||||||
|
"a",
|
||||||
|
"--overwrite",
|
||||||
|
"--out",
|
||||||
|
])
|
||||||
|
.arg(out);
|
||||||
|
} else {
|
||||||
|
cmd.args(["wait-ms", if mode == "ordinary" { "1ms" } else { "60s" }]);
|
||||||
|
}
|
||||||
|
cmd.arg("--json");
|
||||||
|
if mode == "ordinary" {
|
||||||
|
cmd.stdin(std::process::Stdio::null());
|
||||||
|
} else {
|
||||||
|
cmd.env("BSK_CANCEL_ON_STDIN_CLOSE", "1")
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
}
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
if mode != "ordinary" {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), registered.notified())
|
||||||
|
.await
|
||||||
|
.expect("CLI registered business RPC");
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b"still connected")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(child.stdin.take());
|
||||||
|
}
|
||||||
|
let start = Instant::now();
|
||||||
|
let output = tokio::time::timeout(Duration::from_secs(8), child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.expect("CLI must settle promptly, including cleanup")
|
||||||
|
.unwrap();
|
||||||
|
server.abort();
|
||||||
|
let _ = server.await;
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(if mode == "ordinary" { 0 } else { 2 }),
|
||||||
|
"{mode}: stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
if mode != "ordinary" {
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
assert_eq!(body["code"], "cancelled");
|
||||||
|
if mode != "upload-before-dispatch" {
|
||||||
|
assert_eq!(body["message"], "parent cancellation reached daemon");
|
||||||
|
}
|
||||||
|
let received = received.lock().unwrap();
|
||||||
|
let cancel = received.iter().position(|m| *m == Method::Cancel).unwrap();
|
||||||
|
assert!(
|
||||||
|
received[cancel + 1..]
|
||||||
|
.iter()
|
||||||
|
.all(|m| *m == Method::TransferRelease),
|
||||||
|
"only cleanup may follow cancellation: {received:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ids = ids.lock().unwrap();
|
||||||
|
if mode.contains("stall") {
|
||||||
|
assert_eq!(released.lock().unwrap().len(), 1);
|
||||||
|
assert!(start.elapsed() >= Duration::from_secs(4));
|
||||||
|
// The CLI cannot force an unresponsive daemon to clean up.
|
||||||
|
transfers.release_session("fixture");
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
*released.lock().unwrap(),
|
||||||
|
*ids,
|
||||||
|
"{mode}: every allocation must be released"
|
||||||
|
);
|
||||||
|
for id in ids.iter() {
|
||||||
|
assert!(!staging_root.join(id).exists(), "staging leaked: {id}");
|
||||||
|
}
|
||||||
|
if mode.starts_with("upload") {
|
||||||
|
let retry = transfers
|
||||||
|
.begin_upload(TransferBeginParams {
|
||||||
|
session_id: "fixture".into(),
|
||||||
|
name: "retry.txt".into(),
|
||||||
|
byte_size: 300 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
.expect("same session must have room for another 300 MiB upload");
|
||||||
|
transfers.release(TransferIdParams {
|
||||||
|
transfer_id: retry.transfer_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "download" {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(home.path().join("out.txt")).unwrap(),
|
||||||
|
b"existing output"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!std::fs::read_dir(home.path())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.any(|entry| entry.file_name().to_string_lossy().ends_with(".part"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+41
-15
@@ -21,6 +21,7 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
|
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
|
||||||
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||||
|
$InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
|
||||||
$GitHub = "https://github.com/${Repo}"
|
$GitHub = "https://github.com/${Repo}"
|
||||||
|
|
||||||
function Write-Log {
|
function Write-Log {
|
||||||
@@ -64,7 +65,7 @@ function Get-PlatformTriple {
|
|||||||
function Add-ToUserPath {
|
function Add-ToUserPath {
|
||||||
param([string]$Dir)
|
param([string]$Dir)
|
||||||
|
|
||||||
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
|
$currentUserPath = @([Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ })
|
||||||
|
|
||||||
if ($currentUserPath -contains $Dir) {
|
if ($currentUserPath -contains $Dir) {
|
||||||
Write-Log "$Dir is already in your user PATH"
|
Write-Log "$Dir is already in your user PATH"
|
||||||
@@ -90,30 +91,59 @@ function Add-ToSessionPath {
|
|||||||
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
|
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
|
||||||
|
|
||||||
function Add-ToBashProfile {
|
function Add-ToBashProfile {
|
||||||
param([string]$Dir)
|
param([string]$Dir, [string]$BashRc = (Join-Path $HOME ".bashrc"))
|
||||||
|
|
||||||
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
|
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
|
||||||
$unixPath = $Dir -replace '\\', '/'
|
$unixPath = $Dir -replace '\\', '/'
|
||||||
if ($unixPath -match '^([A-Z]):(.*)$') {
|
if ($unixPath -match '^([A-Z]):(.*)$') {
|
||||||
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
|
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
|
||||||
}
|
}
|
||||||
$bashRc = Join-Path $HOME ".bashrc"
|
# Single-quote the literal directory; only the existing PATH is expanded.
|
||||||
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
|
$shellQuote = "'" + [char]34 + "'" + [char]34 + "'"
|
||||||
|
$quotedPath = "'" + $unixPath.Replace("'", $shellQuote) + "'"
|
||||||
|
$exportLine = "export PATH=${quotedPath}:`"`$PATH`" # bsk CLI"
|
||||||
|
|
||||||
if (Test-Path $bashRc) {
|
if (Test-Path -LiteralPath $BashRc) {
|
||||||
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
|
$content = [System.IO.File]::ReadAllText($BashRc)
|
||||||
if ($content -match [regex]::Escape($unixPath)) {
|
if ($content.Contains($exportLine)) {
|
||||||
Write-Log "$unixPath is already in ~/.bashrc"
|
Write-Log "$unixPath is already in ~/.bashrc"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
|
# Explicit BOM-less UTF-8 also works in Windows PowerShell 5.1.
|
||||||
|
[System.IO.File]::AppendAllText($BashRc, "`n$exportLine`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||||
Write-Log "added ${unixPath} to ~/.bashrc"
|
Write-Log "added ${unixPath} to ~/.bashrc"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Stage on the destination volume before stopping the daemon. Never truncate
|
||||||
|
# the installed executable: failed replacement must leave it usable.
|
||||||
|
function Install-Binary {
|
||||||
|
param([string]$Source, [string]$Target)
|
||||||
|
|
||||||
|
$staged = "$Target.install-$([Guid]::NewGuid().ToString('N'))"
|
||||||
|
try {
|
||||||
|
[System.IO.File]::Copy($Source, $staged)
|
||||||
|
if ([System.IO.File]::Exists($Target)) {
|
||||||
|
# Use the downloaded CLI: older versions have broken Windows
|
||||||
|
# liveness checks. Stop verifies daemon identity before terminating it.
|
||||||
|
& $Source daemon stop
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; existing installation was not replaced" }
|
||||||
|
# PowerShell 5.1 converts $null to an empty path for string parameters.
|
||||||
|
[System.IO.File]::Replace($staged, $Target, [NullString]::Value)
|
||||||
|
Write-Log "daemon will restart automatically on the next browser command"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[System.IO.File]::Move($staged, $Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Main {
|
function Main {
|
||||||
$platform = Get-PlatformTriple
|
$platform = Get-PlatformTriple
|
||||||
|
|
||||||
@@ -184,7 +214,7 @@ function Main {
|
|||||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
|
Install-Binary -Source (Join-Path $tempDir "bsk.exe") -Target (Join-Path $InstallDir "bsk.exe")
|
||||||
|
|
||||||
Write-Log "installed bsk to $InstallDir\bsk.exe"
|
Write-Log "installed bsk to $InstallDir\bsk.exe"
|
||||||
|
|
||||||
@@ -199,12 +229,8 @@ function Main {
|
|||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
$bskPath = Join-Path $InstallDir "bsk.exe"
|
$bskPath = Join-Path $InstallDir "bsk.exe"
|
||||||
if (Get-Command bsk -ErrorAction SilentlyContinue) {
|
& $bskPath --version
|
||||||
& bsk --version
|
if ($LASTEXITCODE -ne 0) { throw "installed bsk failed verification" }
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Log "verify install: & ""$bskPath"" --version"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Log "done"
|
Write-Log "done"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
|
||||||
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
|
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process";
|
||||||
|
|
||||||
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
||||||
export interface BskErrorBody {
|
export interface BskErrorBody {
|
||||||
@@ -53,7 +53,11 @@ export interface BskRunOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal spawn signature so tests can substitute a fake child process. */
|
/** Minimal spawn signature so tests can substitute a fake child process. */
|
||||||
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
|
export type SpawnImpl = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: SpawnOptionsWithoutStdio,
|
||||||
|
) => ChildProcess;
|
||||||
|
|
||||||
export interface BskRunner {
|
export interface BskRunner {
|
||||||
/** Run `bsk <args...> --json` and collect its output. */
|
/** Run `bsk <args...> --json` and collect its output. */
|
||||||
@@ -64,29 +68,66 @@ export interface BskRunner {
|
|||||||
killFor(tag: string): number;
|
killFor(tag: string): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
|
// Business RPCs translate Ctrl-C / opt-in stdin EOF into cancel(rpc_id).
|
||||||
// Give that bounded reconciliation path time to settle before the hard kill.
|
// Allow reconciliation before hard-killing an old or unresponsive CLI.
|
||||||
const KILL_GRACE_MS = 3000;
|
const KILL_GRACE_MS = 3000;
|
||||||
|
// Windows IPC may spend 5s connecting, 2s cancelling, 2s settling,
|
||||||
|
// and up to 5s releasing the entire batch of caller-owned transfers.
|
||||||
|
const WINDOWS_KILL_GRACE_MS = 15_000;
|
||||||
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
||||||
|
|
||||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||||
const live = new Map<ChildProcess, string | undefined>();
|
const live = new Map<ChildProcess, string | undefined>();
|
||||||
|
const windows = process.platform === "win32";
|
||||||
|
const cancelling = new Set<ChildProcess>();
|
||||||
|
|
||||||
function killChild(child: ChildProcess): void {
|
function killChild(child: ChildProcess): void {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
if (child.exitCode !== null || child.signalCode !== null || cancelling.has(child)) return;
|
||||||
child.kill("SIGINT");
|
cancelling.add(child);
|
||||||
const force = setTimeout(() => {
|
// Node kills Windows children outright for SIGINT. EOF asks the CLI to
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
// send its existing cancel RPC and wait for browser reconciliation.
|
||||||
}, KILL_GRACE_MS);
|
if (windows && child.stdin) child.stdin.end();
|
||||||
|
else child.kill("SIGINT");
|
||||||
|
const force = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||||
|
},
|
||||||
|
windows ? WINDOWS_KILL_GRACE_MS : KILL_GRACE_MS,
|
||||||
|
);
|
||||||
force.unref();
|
force.unref();
|
||||||
|
child.once("close", () => {
|
||||||
|
clearTimeout(force);
|
||||||
|
cancelling.delete(child);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run(args, options = {}) {
|
run(args, options = {}) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
return Promise.resolve({
|
||||||
|
code: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return new Promise<BskRunResult>((resolve, reject) => {
|
return new Promise<BskRunResult>((resolve, reject) => {
|
||||||
let child: ChildProcess;
|
let child: ChildProcess;
|
||||||
try {
|
try {
|
||||||
child = spawnImpl(bskPath, [...args, "--json"]);
|
child = spawnImpl(
|
||||||
|
bskPath,
|
||||||
|
[...args, "--json"],
|
||||||
|
windows
|
||||||
|
? {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
// A child exiting while cancellation closes stdin may report EPIPE.
|
||||||
|
// Its close/error event remains the authority for the run result.
|
||||||
|
child.stdin?.on("error", () => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it } from "vitest";
|
import { PassThrough } from "node:stream";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
type BskRunResult,
|
type BskRunResult,
|
||||||
createBskRunner,
|
createBskRunner,
|
||||||
@@ -92,6 +93,77 @@ describe("createBskRunner", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Windows parent cancellation", () => {
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||||
|
beforeEach(() => Object.defineProperty(process, "platform", { value: "win32" }));
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, "platform", originalPlatform);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opts in to stdin cancellation and waits for CLI reconciliation", async () => {
|
||||||
|
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const spawn = vi.fn(() => child as unknown as ChildProcess);
|
||||||
|
const runner = createBskRunner("bsk", spawn);
|
||||||
|
const abort = new AbortController();
|
||||||
|
const result = runner.run(["snapshot"], { signal: abort.signal });
|
||||||
|
expect(spawn.mock.calls[0]).toEqual([
|
||||||
|
"bsk",
|
||||||
|
["snapshot", "--json"],
|
||||||
|
{
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
abort.abort();
|
||||||
|
expect(child.stdin.writableEnded).toBe(true);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
// Closing an already-exited child's pipe must not crash the host.
|
||||||
|
child.stdin.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }));
|
||||||
|
child.finish(2, '{"code":"cancelled"}');
|
||||||
|
expect(await result).toMatchObject({ code: 2, aborted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds cancellation of an old or unresponsive CLI", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const runner = createBskRunner("bsk", fakeSpawn([child]));
|
||||||
|
const result = runner.run(["snapshot"], { timeoutMs: 5 });
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
expect(child.stdin.writableEnded).toBe(true);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(child.killedWith).toEqual(["SIGKILL"]);
|
||||||
|
expect(await result).toMatchObject({ timedOut: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels only the requested tag and clears the fallback after exit", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const a = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const b = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const runner = createBskRunner("bsk", fakeSpawn([a, b]));
|
||||||
|
const first = runner.run(["snapshot"], { tag: "a" });
|
||||||
|
const second = runner.run(["snapshot"], { tag: "b" });
|
||||||
|
expect(runner.killFor("a")).toBe(1);
|
||||||
|
expect(a.stdin.writableEnded).toBe(true);
|
||||||
|
expect(b.stdin.writableEnded).toBe(false);
|
||||||
|
a.finish(2);
|
||||||
|
b.finish(0);
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start work for an already-aborted call", async () => {
|
||||||
|
const spawn = vi.fn();
|
||||||
|
const runner = createBskRunner("bsk", spawn);
|
||||||
|
const signal = AbortSignal.abort();
|
||||||
|
expect(await runner.run(["snapshot"], { signal })).toMatchObject({ aborted: true });
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseBskJson", () => {
|
describe("parseBskJson", () => {
|
||||||
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
|
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
param([string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"))
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Load definitions, never Main: no downloads or changes to the real user PATH.
|
||||||
|
$installer = Join-Path $PSScriptRoot "../install.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$errors = $null
|
||||||
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors)
|
||||||
|
if ($errors.Count) { throw ($errors | Out-String) }
|
||||||
|
foreach ($statement in $ast.EndBlock.Statements) {
|
||||||
|
if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
|
||||||
|
$definition = $statement.Extent.Text
|
||||||
|
if ($statement.Name -eq "Add-ToUserPath") {
|
||||||
|
$definition = $definition.Replace('[Environment]::GetEnvironmentVariable("PATH", "User")', '$script:UserPath')
|
||||||
|
$definition = $definition.Replace('[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")', '$script:UserPath = $newUserPath')
|
||||||
|
}
|
||||||
|
Invoke-Expression $definition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Assert-Equal($Actual, $Expected) {
|
||||||
|
if ($Actual -cne $Expected) { throw "expected [$Expected], got [$Actual]" }
|
||||||
|
}
|
||||||
|
function Assert-Fails([scriptblock]$Action) {
|
||||||
|
$failed = $false
|
||||||
|
try { & $Action } catch { $failed = $true }
|
||||||
|
if (-not $failed) { throw "expected failure" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = 'C:\Users\Alice\.local\bin'
|
||||||
|
foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) {
|
||||||
|
$script:UserPath = $existing
|
||||||
|
Add-ToUserPath $dir
|
||||||
|
$expected = if ($existing) { "$existing;$dir" } else { $dir }
|
||||||
|
Assert-Equal $script:UserPath $expected
|
||||||
|
Add-ToUserPath $dir
|
||||||
|
Assert-Equal $script:UserPath $expected
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = Join-Path ([IO.Path]::GetTempPath()) ("bsk-install-test-" + [Guid]::NewGuid().ToString('N'))
|
||||||
|
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||||
|
$oldBskHome = $env:BSK_HOME
|
||||||
|
$oldAutoUpdate = $env:BSK_AUTO_UPDATE
|
||||||
|
$daemon = $null
|
||||||
|
try {
|
||||||
|
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$bashRc = Join-Path $root ".bashrc"
|
||||||
|
$existing = "# existing 中文 configuration" + [char]10
|
||||||
|
[IO.File]::WriteAllText($bashRc, $existing, $utf8)
|
||||||
|
$special = 'C:\Users\张三 space $HOME $(echo unsafe) ! & [x] O''Brien\.local\bin'
|
||||||
|
Add-ToBashProfile $special $bashRc
|
||||||
|
$first = [IO.File]::ReadAllText($bashRc)
|
||||||
|
if (-not $first.StartsWith($existing)) { throw "existing bashrc content changed" }
|
||||||
|
if (-not $first.Contains('张三')) { throw "Unicode path was lost" }
|
||||||
|
Add-ToBashProfile $special $bashRc
|
||||||
|
Assert-Equal ([IO.File]::ReadAllText($bashRc)) $first
|
||||||
|
$bytes = [IO.File]::ReadAllBytes($bashRc)
|
||||||
|
if ($bytes[0] -eq 0xEF) { throw "unexpected UTF-8 BOM" }
|
||||||
|
|
||||||
|
$bash = [IO.Path]::GetFullPath((Join-Path (Split-Path (Get-Command git).Source -Parent) "../bin/bash.exe"))
|
||||||
|
if (-not (Test-Path -LiteralPath $bash)) { throw "Git Bash is required for quoting regression" }
|
||||||
|
$env:BSK_TEST_RC = $bashRc
|
||||||
|
$probe = Join-Path $root 'probe.sh'
|
||||||
|
[IO.File]::WriteAllText($probe, 'source "$BSK_TEST_RC"; printf "%s\n" "${PATH%%:*}"', $utf8)
|
||||||
|
$actual = & $bash --noprofile --norc $probe
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Git Bash failed" }
|
||||||
|
Assert-Equal $actual ('/c' + $special.Substring(2).Replace('\', '/'))
|
||||||
|
|
||||||
|
$env:BSK_HOME = Join-Path $root "home"
|
||||||
|
$env:BSK_AUTO_UPDATE = "off"
|
||||||
|
[IO.Directory]::CreateDirectory($env:BSK_HOME) | Out-Null
|
||||||
|
$sourceDir = Join-Path $root "download"
|
||||||
|
[IO.Directory]::CreateDirectory($sourceDir) | Out-Null
|
||||||
|
$source = Join-Path $sourceDir "bsk.exe"
|
||||||
|
[IO.File]::Copy((Resolve-Path -LiteralPath $BskPath).Path, $source)
|
||||||
|
$targetDir = Join-Path $root "中文 space [x] & install"
|
||||||
|
[IO.Directory]::CreateDirectory($targetDir) | Out-Null
|
||||||
|
$target = Join-Path $targetDir "bsk.exe"
|
||||||
|
Install-Binary $source $target
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||||
|
|
||||||
|
$daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru
|
||||||
|
$infoPath = Join-Path $env:BSK_HOME "daemon.json"
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
||||||
|
while (-not [IO.File]::Exists($infoPath)) {
|
||||||
|
if ($daemon.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw "daemon failed to start" }
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
# PE overlays distinguish old/new executables without another build.
|
||||||
|
$stream = [IO.File]::OpenWrite($source)
|
||||||
|
try {
|
||||||
|
$stream.Seek(0, [IO.SeekOrigin]::End) | Out-Null
|
||||||
|
$marker = $utf8.GetBytes("installer replacement regression")
|
||||||
|
$stream.Write($marker, 0, $marker.Length)
|
||||||
|
} finally { $stream.Dispose() }
|
||||||
|
Install-Binary $source $target
|
||||||
|
if (-not $daemon.WaitForExit(5000)) { throw "old daemon still running" }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||||
|
& $target --version
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "replacement is not executable" }
|
||||||
|
|
||||||
|
# Refuse replacement when daemon identity cannot be verified.
|
||||||
|
[IO.File]::WriteAllText($infoPath, 'invalid daemon metadata')
|
||||||
|
$before = (Get-FileHash -LiteralPath $target).Hash
|
||||||
|
Assert-Fails { Install-Binary $source $target }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||||
|
[IO.File]::Delete($infoPath)
|
||||||
|
|
||||||
|
# A remaining lock must fail without truncation or staging debris.
|
||||||
|
$lock = [IO.File]::Open($target, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
|
||||||
|
try { Assert-Fails { Install-Binary $source $target } } finally { $lock.Dispose() }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||||
|
if (@(Get-ChildItem -LiteralPath $targetDir -Filter "*.install-*").Count) { throw "staging files leaked" }
|
||||||
|
Write-Host "Windows installer regressions passed ($($PSVersionTable.PSVersion))"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host ($_ | Out-String)
|
||||||
|
Write-Host $_.ScriptStackTrace
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($daemon -and -not $daemon.HasExited) {
|
||||||
|
Stop-Process -Id $daemon.Id -Force
|
||||||
|
$daemon.WaitForExit(5000) | Out-Null
|
||||||
|
}
|
||||||
|
$env:BSK_HOME = $oldBskHome
|
||||||
|
$env:BSK_AUTO_UPDATE = $oldAutoUpdate
|
||||||
|
Remove-Item Env:BSK_TEST_RC -ErrorAction SilentlyContinue
|
||||||
|
# Resolve and verify before recursive cleanup; only this fixture is removed.
|
||||||
|
$resolvedRoot = [IO.Path]::GetFullPath($root)
|
||||||
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||||||
|
if (-not $resolvedRoot.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
-not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('bsk-install-test-')) { throw "invalid cleanup path" }
|
||||||
|
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
|
||||||
|
}
|
||||||
+9
-3
@@ -174,16 +174,22 @@ function updateChangelog(version) {
|
|||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const escapedVer = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedVer = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const versionHeading = new RegExp(`^## \\[${escapedVer}\\](?:\\s|$)`, "m");
|
||||||
|
|
||||||
// First: fill in any date-placeholder line for this version (e.g. "## [0.2.0] - 2026-09-XX")
|
// First: fill in any date-placeholder line for this version (e.g. "## [0.2.0] - 2026-09-XX")
|
||||||
const versionPlaceholder = new RegExp(`^## \\[${escapedVer}\\]\\s*-\\s*\\d{4}-\\d{2}-XX`, "m");
|
const versionPlaceholder = new RegExp(`^## \\[${escapedVer}\\]\\s*-\\s*\\d{4}-\\d{2}-XX`, "m");
|
||||||
if (versionPlaceholder.test(content)) {
|
if (versionPlaceholder.test(content)) {
|
||||||
content = content.replace(versionPlaceholder, `## [${version}] - ${today}`);
|
content = content.replace(versionPlaceholder, `## [${version}] - ${today}`);
|
||||||
} else {
|
} else if (!versionHeading.test(content)) {
|
||||||
// Otherwise: insert a new section after [Unreleased]
|
// Preserve prepared release entries; new versions go before the release history.
|
||||||
|
const heading = `## [${version}] - ${today}`;
|
||||||
const unreleased = /^## \[Unreleased\]/m;
|
const unreleased = /^## \[Unreleased\]/m;
|
||||||
if (unreleased.test(content)) {
|
if (unreleased.test(content)) {
|
||||||
content = content.replace(unreleased, `## [Unreleased]\n\n## [${version}] - ${today}`);
|
content = content.replace(unreleased, `## [Unreleased]\n\n${heading}`);
|
||||||
|
} else if (/^## /m.test(content)) {
|
||||||
|
content = content.replace(/^## /m, `${heading}\n\n## `);
|
||||||
|
} else {
|
||||||
|
content = `${content.trimEnd()}\n\n${heading}\n`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user