diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b953b..f385135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## Unreleased — 2026-10-13 + +**类型**:fix(route B 工具/预设生命周期、动态配置与客户端回归) / test。 + +- **复现与裁定**:两名 Sol 分区审查、Grok 交叉反证与主代理隔离复验确认了工具面、生命周期、 + 组合、代码扫描和客户端状态转换的问题;普通子 scope 的原始冲突仍由 v0.39.1 的 `shell` 隔离修复。 + FastCtx 全文路径回显是既定策略,不误当文件工具替换问题;minimal 的 persistent PTY 不与全工具 + 变体的执行器来源混为一谈。只修既有功能,不新增功能、改 root shell 或市场 patch。 +- **变体接线**:Windows 全工具 Git Bash 变体不再启用依赖 root PowerShell 的官方 `tool-bash`; + 保留官方 pwsh 面,经同一行内补位通道接入私有 Git Bash 工具。minimal 的 persistent 通道保持。 +- **工具生命周期**:存活 agent 的 `pwshTool` 双向翻转同步解除/重建自身的 restrict;异步导入/ + 挂载在插件或 agent 退役后不再迟到注册;jobs 服务卸载按真实 Cordis 活跃状态恢复前台 bash。 +- **降级分支**:plain definition 的 required 标志递归转换为合法 JSON Schema;升级助手不可用 + 时仍解析并传递每会话 standing policy,不错误回退部署默认。两项为模块解析降级的受控复现, + 不宣称本机生产已发生导入失败,也不改变 Windows 受限模式明示 unconfined 的既定取舍。 +- **预设与默认值**:串行 reconcile 退役时关闭新任务、清理晚到 register 并等待 unregister; + 动态关闭变体在退役前迁回部署默认,避免后续新会话引用不存在的 preset。既有钉住会话不迁移。 +- **路径与终端**:run_code 扫描辨认关键字后的正则并在不确定时整份 no-op,不改正则数据; + FastCtx 入/出参获得 execution 上下文并服从总方言、委托与虚拟挂载开关;better-sidebar 的 + adoptSidebar 动态翻转恢复接管前值并保护接管后的用户手选,不改变官方 autoTerminalShell + 关后只停止未来写入的独立语义。 +- **FastCtx 错误**:子进程/写管道 error 进入初始化 Promise,失败清理 pending 计时器与连接; + MCP `isError:true` 按官方桥约定抛错,不再把失败包装成宿主成功 value。 + 发现与连接启动采用 single-flight,拥有在途连接并在卸载时取消/等待,不让并发 agent 的发现流程 + 覆盖已在使用的 lazy client。该丢失所有权链已用受控 MCP 传输复现,并新增关闭/晚注册回归。 +- **客户端**:设置卡所有 Hooks 在 readiness early-return 前调用;工具链进度轮询在网络/HTTP + 暂态失败后重试,卸载或失效时取消,不因单次请求失败永久卡在忙碌状态。 +- **回归入口**:新增可移植客户端/桥/运行时/宿主回归与实际 Core preview 集成用例,接入现有 + `npm test`/`npm run test:preview`;不新增依赖、不跳过旧断言或 Node 22 矩阵。对齐上游 v0.39.1 + (`521be01`)后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查 + 通过。新增执行级回归保留 #21 的 real-array glob 契约与 stringified-array 响亮拒绝;不改已发布 + 版本号、历史记录或 npm 发布流程。Grok 对修复 diff 的只读反证未发现确定遗漏。 + 首轮全量检查发现三条旧源码正则仍锁旧实现,改为对应新保护条件并补同值/手选行为测试,没有删掉 + 保护断言。原 [Issue #19](https://github.com/KannaKuron/dsh-gitbash-shell/issues/19) 和 + [PR #20](https://github.com/KannaKuron/dsh-gitbash-shell/pull/20) 已完成;这些后续修复由新 PR 承载, + Windows/Linux × Node 22/24 的远端结果见其检查。preview 不等同于完整 Electron GUI 或真 MCP + exe 并发验收,模块解析降级使用受控夹具复现。 + ## v0.39.1 — 2026-10-13 **类型**:fix(route B 私有执行器的真实服务隔离与读取授权) / test / ci。 diff --git a/package.json b/package.json index 49c3405..4ffe9dc 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "icon.svg" ], "scripts": { - "test": "node --test tests/smoke.mjs tests/private-executor.mjs", - "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs", + "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs", + "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs", "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json" }, "keywords": [ diff --git a/src/bash-tool.js b/src/bash-tool.js index 6becd51..f4bd5fb 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -248,20 +248,29 @@ function outputSchemaSpec() { } } -/** Plain JSON-schema variant of the same union (fallback when defineTool is absent). */ -function outputSchemaJson() { - const convert = (item) => { - const out = { type: item.type, additionalProperties: item.additionalProperties, properties: {} } - const required = [] - for (const [key, value] of Object.entries(item.properties)) { - if (value.required === true) { required.push(key) } - const { required: _drop, ...rest } = value - if (rest.oneOf !== undefined) delete rest.required - out.properties[key] = rest +/** Convert key-level spec flags at every depth, including union branches. */ +function schemaSpecToJson(spec) { + const { required: marker, ...out } = spec + if (Array.isArray(marker)) out.required = [...marker] + if (spec.properties !== undefined) { + const required = Array.isArray(marker) ? [...marker] : [] + out.properties = {} + for (const [key, value] of Object.entries(spec.properties)) { + if (value.required === true && !required.includes(key)) required.push(key) + out.properties[key] = schemaSpecToJson(value) } - return required.length > 0 ? { ...out, required } : out + if (required.length > 0) out.required = required } - return { oneOf: outputSchemaSpec().oneOf.map(convert) } + for (const key of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(spec[key])) out[key] = spec[key].map(schemaSpecToJson) + } + if (spec.items !== undefined) out.items = schemaSpecToJson(spec.items) + return out +} + +/** Plain JSON-schema variant of the same union (fallback when defineTool is absent). */ +function outputSchemaJson() { + return schemaSpecToJson(outputSchemaSpec()) } /** The official args validation (index.ts:73). */ @@ -320,8 +329,9 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm const defaultMode = executor.sandboxMode const escalationReady = defaultMode !== undefined && typeof approveEscalation === 'function' const escalationModes = escalationReady ? ESCALATION_TARGETS : [] + // Missing approval helpers narrow escalation, not the session's standing policy. const resolveSandboxPolicy = (exec) => { - if (!escalationReady) return undefined + if (defaultMode === undefined) return undefined const policy = ctx.get('sandboxPolicy') return policy ? policy.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session }) : undefined } @@ -548,7 +558,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm // keys reach execute too, where enableRunInBackground enforces them // ("Undeclared keys are allowed, so schema omission also needs // enforcement", index.ts:498). - parameters: { type: 'object', properties: parametersSpec(), required: ['description', 'command'] }, + parameters: schemaSpecToJson({ type: 'object', properties: parametersSpec() }), output: { schema: outputSchemaJson(), render: (_args, value) => [{ @@ -580,7 +590,8 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm jobCtx.effect(() => () => { unregister() try { - if (ctx.fiber?.state === 'active') foregroundOnly = ctx.tools.register(bashTool(undefined)) + // Cordis FiberState.ACTIVE is the numeric enum member 2. + if (ctx.fiber?.state === 2) foregroundOnly = ctx.tools.register(bashTool(undefined)) } catch { /* the scope is going away */ } }) }) diff --git a/src/client.js b/src/client.js index 699d56b..d68d87f 100644 --- a/src/client.js +++ b/src/client.js @@ -2063,6 +2063,42 @@ window.__ModuleLoader__.load({ if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot(); } catch (error_) { /* keep unavailable */ } + var pageView = props.view === "page"; + + // Hooks run for every snapshot state; readiness only gates their work. + useEffect(function () { + if (snap.status !== "ready" || (!open && !pageView)) return undefined; + if (toolsLoaded || toolBusy) return undefined; + loadTools(); + return undefined; + }, [open, pageView, snap.status]); + + /* Poll ONLY while a job is unfinished, and through the cheap ?job=1 + form. A transient failure retries without discarding progress. */ + useEffect(function () { + if (snap.status !== "ready" || toolJob === null || toolJob.finished) return undefined; + var alive = true; + var timer; + function schedule() { + timer = setTimeout(function () { + fetch(TOOLS_URL + "?job=1", { headers: { accept: "application/json" } }) + .then(function (res) { + if (!res.ok) throw new Error("HTTP " + res.status); + return res.json(); + }) + .then(function (data) { + if (!alive) return; + var next = data !== null && typeof data === "object" && data.job ? data.job : null; + setToolJob(next); + if (next !== null && next.finished) loadTools(); + }) + .catch(function () { if (alive) schedule(); }); + }, 1200); + } + schedule(); + return function () { alive = false; clearTimeout(timer); }; + }, [toolJob, snap.status]); + if (snap.status !== "ready") return null; var value = snap.value || {}; @@ -2135,7 +2171,6 @@ window.__ModuleLoader__.load({ // dsh 0.1.6-alpha.2: the Plugins page renders this card through the // `plugins.bundle.config` slot with view="page" — the page draws the // title itself; the collapsible shell only shows on the popup seat. - var pageView = props.view === "page"; var mounts = snap.value.virtualMounts !== false; var errs = snap.value.errorDialect !== false; @@ -2469,30 +2504,6 @@ window.__ModuleLoader__.load({ .then(function () { setToolBusy(false); }); } - useEffect(function () { - if (!open && !pageView) return undefined; - if (toolsLoaded || toolBusy) return undefined; - loadTools(); - return undefined; - }, [open]); - - /* Poll ONLY while a job is unfinished, and through the cheap ?job=1 - form: a full probe re-runs three winget commands per second. */ - useEffect(function () { - if (toolJob === null || toolJob.finished) return undefined; - var timer = setTimeout(function () { - fetch(TOOLS_URL + "?job=1", { headers: { accept: "application/json" } }) - .then(function (res) { return res.json(); }) - .then(function (data) { - var next = data !== null && typeof data === "object" && data.job ? data.job : null; - setToolJob(next); - if (next !== null && next.finished) loadTools(); - }) - .catch(function () { /* keep the last known progress */ }); - }, 1200); - return function () { clearTimeout(timer); }; - }, [toolJob]); - var toolsSection = fold("sec.tools", toolsData === null && toolErr === "" ? E("p", { className: "gb-hint" }, t("tools.loading")) : null, toolErr !== "" ? E("p", { className: "gb-error" }, toolErr) : null, diff --git a/src/compositions.js b/src/compositions.js index 8cfbe5f..b1eec1b 100644 --- a/src/compositions.js +++ b/src/compositions.js @@ -9,10 +9,10 @@ * * Every variant mirrors the official 0.1.7 preset it replaces (packages/ * bundle/web-app/presets/{standard,minimal,ptc,cordis}.patch.yml) with the - * one Git Bash delta: the bash rows are always on and the pwsh rows are - * always off (the plugin ships its OWN bash tool over a private executor — - * on non-Windows hosts the official stack is native anyway, which is exactly - * what the old materialized variants encoded). A second, conditional delta exists while + * Git Bash routing: full variants keep the official platform-gated shell + * rows, and the inline patch adds OUR bash over a private executor on Windows. + * The minimal variant instead keeps its persistent Git Bash terminal rows. + * On non-Windows hosts the official stack stays native. A conditional delta exists while * dsh-ptc-cordis-preset reports its experimental-CPython backend ACTIVE: the * two workflow rows follow the official Python composition and go off (see * `pythonActive` below). While that backend is off — the default, and also @@ -56,7 +56,8 @@ const planSectionFor = (hostEra021) => hostEra021 * @param {'standard'|'ptc'|'cordis'} input.kind - which official preset this * variant mirrors (workflow rows follow it: enabled on standard/cordis, * disabled on ptc). - * @param {boolean} input.gitBash - bash rows always on, pwsh rows always off. + * @param {boolean} input.gitBash - retained for composition-call compatibility; + * full variants obtain Git Bash from the per-agent inline patch on Windows. * @param {string|undefined} input.skillsDir - Creation authoring skills * directory (cordis variant only; resolved beside the agent-preset package). * @param {boolean} [input.pythonActive] - whether the experimental CPython @@ -75,8 +76,11 @@ const planSectionFor = (hostEra021) => hostEra021 */ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hostExtras = {} }) { const win = typeof process !== 'undefined' && process.platform === 'win32' - const bashDisabled = gitBash ? false : win - const pwshDisabled = gitBash ? true : !win + // Route B preserves the root PowerShell executor. On Windows the official + // tool-bash would execute through that executor, so full variants retain the + // official pwsh face and let the same per-agent inline patch add OUR bash. + const bashDisabled = win + const pwshDisabled = !win const workflowOn = kind !== 'ptc' && pythonActive !== true // dsh 0.2.1 added two rows to every full-tool official preset — time-context // (durable clock readings) and tool-schedule (the reminder tools) — plus a diff --git a/src/fastctx-bridge.js b/src/fastctx-bridge.js index 87534dc..0b667ce 100644 --- a/src/fastctx-bridge.js +++ b/src/fastctx-bridge.js @@ -106,20 +106,21 @@ export class McpStdio { this.child.stderr.on('data', (chunk) => { this.stderrTail = (this.stderrTail + chunk).slice(-2000) }) - this.child.on('exit', (code) => { - this.dead = true - for (const [, { reject }] of [...this.pending]) { - reject(new Error(`the FastCtx server exited (code ${code ?? 'null'})`)) - } - this.pending.clear() - }) - await this.#request('initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'dsh-gitbash-shell', version: '0.35.0' }, - }) - this.#notify('notifications/initialized') - return this + this.child.on('error', (error) => this.#fail(error)) + this.child.stdin.on('error', (error) => this.#fail(error)) + this.child.on('exit', (code) => this.#fail(new Error(`the FastCtx server exited (code ${code ?? 'null'})`))) + try { + await this.#request('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'dsh-gitbash-shell', version: '0.35.0' }, + }) + this.#notify('notifications/initialized') + return this + } catch (error) { + await this.close() + throw error + } } /** List the server's tools: [{ name, description, inputSchema }]. */ @@ -140,15 +141,21 @@ export class McpStdio { this.dead = true const child = this.child this.child = undefined - for (const [, { reject }] of [...this.pending]) { - reject(new Error('the FastCtx bridge was stopped')) - } - this.pending.clear() + this.#fail(new Error('the FastCtx bridge was stopped')) if (child && child.exitCode === null && !child.killed) { try { child.kill() } catch { /* already gone */ } } } + #fail(error) { + this.dead = true + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer) + reject(error) + } + this.pending.clear() + } + #onChunk(chunk) { this.buffer += chunk let at = this.buffer.indexOf('\n') @@ -310,6 +317,10 @@ export function mountFastctx(ctx, deps) { let client = undefined let decided = false let withdrawnByOps = false + let closed = false + let publishing + let connecting + const starting = new Set() const unpublish = (reason) => { if (disposer) { @@ -320,16 +331,34 @@ export function mountFastctx(ctx, deps) { } const callWithReconnect = async (rawName, args) => { + if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') if (client === undefined || client.isDead) { - const { file } = resolveFastctx(ctx, createRequireFrom) - if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry') - client = await new McpStdio({ file, log }).start() + if (!connecting) { + connecting = (async () => { + const { file } = resolveFastctx(ctx, createRequireFrom) + if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry') + const fresh = new McpStdio({ file, log }) + starting.add(fresh) + try { + await fresh.start() + if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') + client = fresh + } catch (error) { + await fresh.close() + throw error + } finally { + starting.delete(fresh) + } + })().finally(() => { connecting = undefined }) + } + await connecting } + if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped') return client.callTool(rawName, args) } - const publish = async () => { - if (disposer || decided && withdrawnByOps) return + const publishOnce = async () => { + if (closed || disposer || decided && withdrawnByOps) return const registry = tools(ctx) if (!registry || typeof registry.register !== 'function') return const { file, source } = resolveFastctx(ctx, createRequireFrom) @@ -340,6 +369,7 @@ export function mountFastctx(ctx, deps) { return } const fresh = new McpStdio({ file, log }) + starting.add(fresh) let toolList try { await fresh.start() @@ -350,9 +380,12 @@ export function mountFastctx(ctx, deps) { decided = true withdrawnByOps = true return + } finally { + starting.delete(fresh) } await fresh.close() - client = undefined // the first call re-launches lazily; boot stays cheap + if (closed || withdrawnByOps) return + // Discovery never overwrites the independently owned lazy call client. const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name))) const registered = [] const registeredNames = [] @@ -386,16 +419,20 @@ export function mountFastctx(ctx, deps) { schema: TOOL_OUTPUT_SCHEMA, render: (_args, value) => renderTextContent(value), }, - async execute(args) { - // Fail LOUD on malformed patterns (v0.38.5; reshaped v0.39.1, - // issue #21 — see validateFastctxPattern). + async execute(args, execution) { + // Preserve v0.39.1 / #21: glob accepts real string arrays, + // while stringified arrays and malformed patterns fail loudly. if (args !== null && typeof args === 'object' && 'pattern' in args) { const invalid = validateFastctxPattern(rawName, args.pattern) if (invalid) throw new Error(invalid) } - const translated = translateFastctxArgs(args, msysToWin) + const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined) const result = await callWithReconnect(rawName, translated) - return { content: [{ type: 'text', text: renderFastctxResult(rawName, result, echo) }] } + const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined) + // The host treats a returned value as success; MCP failures must + // throw, just as its official MCP client does. + if (result?.isError === true) throw new Error(text) + return { content: [{ type: 'text', text }] } }, }) if (typeof dispose === 'function') { @@ -416,7 +453,13 @@ export function mountFastctx(ctx, deps) { log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`) } + const publish = () => { + if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined }) + return publishing + } + const reconcile = (agent) => { + if (closed) return if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return } if (decided && withdrawnByOps) return let names = [] @@ -440,8 +483,12 @@ export function mountFastctx(ctx, deps) { log(`FastCtx bridge wiring failed: ${error?.message ?? error}`) return } - ctx.effect(() => () => { + ctx.effect(() => async () => { + closed = true unpublish() - if (client) { void client.close().catch(() => {}) } + const owned = new Set([...starting, client].filter(Boolean)) + await Promise.allSettled([...owned].map((connection) => connection.close())) + await Promise.allSettled([publishing, connecting].filter(Boolean)) + client = undefined }, 'dsh-gitbash-shell: FastCtx bridge') } diff --git a/src/index.js b/src/index.js index d03e735..3413e3b 100644 --- a/src/index.js +++ b/src/index.js @@ -1168,7 +1168,13 @@ const CODE_LITERAL_SAFE = /^[A-Za-z0-9._@+~\-/ ]+$/ /** Previous significant character that lets a '/' start a regex literal. */ function regexCanStart(previous) { if (previous === '') return true - return '(,=:[!&|?{};+-*%<>~^'.indexOf(previous) >= 0 + if (['return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof', 'in', 'instanceof', 'of', 'else', 'do', 'break', 'continue', 'debugger'].includes(previous)) return true + return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous) +} + +/** Read an identifier as ONE token: keyword-led regexes are not division. */ +function codeWordAt(code, index) { + return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0] } /** Index after the closing quote of the literal at `index`, or -1. */ @@ -1239,6 +1245,15 @@ function skipBracedExpression(code, index) { last = 'x' continue } + // A slash after a closing group may start a statement regex OR division. + // Without a parser that distinction is uncertain: abort the WHOLE scan. + if (c === '/' && (last === ')' || last === '}')) return -1 + const word = codeWordAt(code, i) + if (word) { + last = last === '.' ? 'x' : word + i += word.length + continue + } if (c === '/' && regexCanStart(last)) { const end = skipRegexLiteral(code, i) if (end === -1) return -1 @@ -1320,6 +1335,15 @@ export function scanCodeLiterals(code) { last = 'x' continue } + // A slash after a closing group may start a statement regex OR division. + // Without a parser that distinction is uncertain: abort the WHOLE scan. + if (c === '/' && (last === ')' || last === '}')) return null + const word = codeWordAt(code, i) + if (word) { + last = last === '.' ? 'x' : word + i += word.length + continue + } if (c === '/' && regexCanStart(last)) { const end = skipRegexLiteral(code, i) if (end === -1) return null @@ -1380,8 +1404,8 @@ export function rewriteCodePaths(code, env) { // is written into, and a quote inside it (a home directory with an // apostrophe) would end that literal early — refuse rather than corrupt. const quote = code[span.start - 1] - if (translated.indexOf(quote) >= 0) continue - if (quote === '`' && translated.indexOf('${') >= 0) continue + if (translated.indexOf(quote) >= 0) return code + if (quote === '`' && translated.indexOf('${') >= 0) return code const source = translated.replace(/\\/g, '\\\\') out += code.slice(cursor, span.start) + source cursor = span.end @@ -1469,9 +1493,6 @@ const SIDEBAR_NS = 'dsh-better-sidebar' const SIDEBAR_TRIES = 12 const SIDEBAR_RETRY_MS = 1500 -// Reconciler installed by adoptSidebarShell; a no-op until then. -let reconcileSidebar = () => {} - /** * Reconcile the better-sidebar `terminalShell` takeover with the adoptSidebar * setting (v0.15.0, default ON). ON writes our bashPath (remembering what was @@ -1491,95 +1512,69 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) { let previous = '' let tried = 0 let timer = null - ctx.effect(() => () => { if (timer) clearTimeout(timer) }, 'dsh-gitbash-shell: sidebar adoption polling') - + let closed = false + let serial = Promise.resolve() const readShell = () => { const settings = ctx.get('settings') if (!settings || typeof settings.update !== 'function') return null try { - // The sidebar's value lives in ITS row Config, projected into the - // settings describe() list; the service exposes describe/update only - // (the old `get(ns)` reader died with dsh 0.1.6). - if (typeof settings.describe === 'function') { - const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === SIDEBAR_NS) - const value = entry === undefined ? undefined : entry.value - return value && typeof value === 'object' && typeof value.terminalShell === 'string' ? value.terminalShell : '' - } - return null - } catch { - return null // namespace not registered yet / sidebar absent + if (typeof settings.describe !== 'function') return null + const entry = settings.describe().find((row) => row && row.ns === SIDEBAR_NS) + const value = entry?.value + return value && typeof value.terminalShell === 'string' ? value.terminalShell : '' + } catch { return null } + } + const restore = async () => { + if (!adopted) return + const now = readShell() + if (now === null) return + // The user may have selected another shell since our takeover. + if (now === bashPath) { + await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: previous }) + console.log(TAG + " better-sidebar shell restored ('" + previous + "')") } + adopted = false } - - reconcileSidebar = (desired) => { + const reconcileSidebar = async (desired) => { const current = readShell() if (current === null) return false - const settings = ctx.get('settings') - if (desired && current !== bashPath) { - const was = current - settings.update(SIDEBAR_NS, { terminalShell: bashPath }) - .then(() => { - previous = was - adopted = true - console.log( - `${TAG} dsh-better-sidebar terminal shell -> Git Bash (${bashPath})` + - (was ? ` (took over from '${was}')` : ''), - ) - }) - .catch((error) => { - console.log(`${TAG} better-sidebar shell adoption unavailable: ${error?.message ?? error}`) - }) - return true - } - if (desired && current === bashPath) { - adopted = true - return true - } - if (!desired && adopted && current === bashPath) { - settings.update(SIDEBAR_NS, { terminalShell: previous }) - .then(() => { - adopted = false - console.log(`${TAG} dsh-better-sidebar terminal shell restored ('${previous || 'sidebar default'}') — adoptSidebar off`) - }) - .catch(() => {}) - return true - } + if (!desired) { await restore(); return true } + if (adopted || current === bashPath) return true + previous = current + await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: bashPath }) + adopted = true + console.log(TAG + ' dsh-better-sidebar terminal shell -> Git Bash (' + bashPath + ')') return true } - const run = () => { - tried += 1 - const ok = reconcileSidebar(readAdopt()) - if (ok) return - if (tried < SIDEBAR_TRIES) { - timer = setTimeout(run, SIDEBAR_RETRY_MS) - return - } - console.log(`${TAG} better-sidebar shell adoption skipped: settings service unavailable after ${tried} tries`) - } - run.watched = false - void run() - - // Dispose: while we still hold the value, restore what was there before. - let reverted = false - ctx.effect( - () => () => { - if (reverted || !adopted) return - reverted = true - const s = ctx.get('settings') - if (!s || typeof s.update !== 'function') return - // Era-aware read (v0.25.1): `settings.get(ns)` is gone on dsh >= 0.1.7, so - // the old call threw straight into the catch and the takeover was NEVER - // reverted when this plugin unloaded — same missed-era-read class as - // issue #8 (readShell above carries both branches). - const now = readShell() - if (now === null) return - if (String(now) === bashPath) { - s.update(SIDEBAR_NS, { terminalShell: previous }).catch(() => {}) - } - }, - 'dsh-gitbash-shell: sidebar shell revert', - ) + if (closed) return serial + if (timer) { clearTimeout(timer); timer = null } + serial = serial.then(async () => { + if (closed) return + tried += 1 + const ok = await reconcileSidebar(readAdopt()) + if (ok || closed) return + if (tried < SIDEBAR_TRIES) timer = setTimeout(run, SIDEBAR_RETRY_MS) + else console.log(TAG + ' better-sidebar shell adoption skipped: settings unavailable') + }).catch((error) => { + console.log(TAG + ' better-sidebar shell adoption unavailable: ' + (error?.message ?? error)) + }) + return serial + } + try { + ctx.on('loader/volatile-update', (paths) => { + if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'adoptSidebar')) return + tried = 0 + void run() + }) + } catch { /* a diagnostic context may have no event service */ } + ctx.effect(() => async () => { + closed = true + if (timer) clearTimeout(timer) + await serial + await restore() + }, 'dsh-gitbash-shell: sidebar shell revert') + return run() } // ── plugin ────────────────────────────────────────────────────────────────── @@ -1759,6 +1754,38 @@ export function minimalPtyBashPath(readResolution) { * failures stay visible through the roster's broken diagnostic instead of * failing the boot. */ +async function migrateVariantDefault(ctx, readVariants) { + if (readVariants() === true) return true + const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } } + const current = readDefault() + if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return true + const target = VARIANT_DEFAULT_SUCCESSORS[current] + try { + const settings = ctx.get('settings') + if (!settings || typeof settings.update !== 'function') return false + const roster = await ctx.agentPresets.list() + if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) { + console.warn(TAG + ' default-preset migration stood down: successor "' + target + '" is not registered. Set an OFFICIAL default or turn variantPresets on.') + return false + } + // A roster read is asynchronous: never overwrite a newer user selection. + if (readVariants() === true || readDefault() !== current) return true + await settings.update('agent-preset-registry', { selectedDefault: target }) + const after = readDefault() + if (after !== target) { + console.warn(TAG + ' default-preset migration did not stick (read back "' + after + '"). Set an OFFICIAL default or turn variantPresets on.') + return false + } + console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target + + '" (variantPresets is off). Pinned sessions are unchanged; agent-preset/locked still requires a NEW session.') + return true + } catch (error) { + console.warn(TAG + ' default-preset migration failed: ' + (error?.message ?? error) + + '. Set an OFFICIAL default or turn variantPresets on; variants were not retired.') + return false + } +} + async function registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras = {}, readBashResolution }) { const meta = PRESET_META[presetId] if (!meta) return undefined @@ -1811,6 +1838,17 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio /** The CPython fact the LIVE registrations were built with. */ let registeredPython = false let serial = Promise.resolve() + let closed = false + ctx.effect(() => async () => { + closed = true + await serial + const failures = [] + for (const [presetId, off] of [...live]) { + live.delete(presetId) + try { await off() } catch (error) { failures.push(error) } + } + if (failures.length) throw new AggregateError(failures, 'Git Bash preset cleanup failed') + }, 'dsh-gitbash-shell: declarative preset registrations') // v0.35.0: the variant roster is opt-in (`variantPresets`, default OFF). // OFF means an EMPTY desired set — the cleanup above still runs, so a @@ -1820,8 +1858,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio : []) const reconcile = () => { + if (closed) return serial serial = serial.then(async () => { + if (closed) return const want = desired() + const pythonActive = peerPython + // Dynamic OFF repairs the deployment default BEFORE retiring its id. + // Keep registrations if the official migration cannot be verified. + if (live.size > 0 && readVariants() !== true + && !await migrateVariantDefault(ctx, readVariants)) return + if (closed) return // The peer's EFFECTIVE CPython backend rewrites the workflow rows of // EVERY variant (the official Python composition disables them, and // workflow-ptc refuses a non-TypeScript backend), so a flip re-registers @@ -1829,14 +1875,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio // as the backend swap itself: the peer's patch is evaluated at boot. A // bare INTENT without the effective backend changes nothing here — the // composition still runs Node, so the workflow rows must stay live. - if (live.size > 0 && registeredPython !== peerPython) { + if (live.size > 0 && registeredPython !== pythonActive) { for (const [presetId, off] of [...live]) { live.delete(presetId) try { await off() console.log(TAG + " preset '" + presetId + "' retired (peer CPython backend changed; rows are rebuilt)") } catch (error) { + live.set(presetId, off) console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error)) + return // never register replacements after an unverified retirement } } } @@ -1847,14 +1895,18 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio await off() console.log(TAG + " preset '" + presetId + "' retired (dsh-ptc-cordis-preset covers Creation mode on Git Bash)") } catch (error) { + live.set(presetId, off) console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error)) + return } } for (const presetId of want) { + if (closed) break if (live.has(presetId)) continue try { - const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive: peerPython, hostExtras, readBashResolution }) + const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras, readBashResolution }) if (unregister) { + if (closed) { await unregister(); break } live.set(presetId, unregister) console.log(TAG + " preset '" + presetId + "' registered declaratively") } @@ -1862,7 +1914,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio console.log(TAG + " preset '" + presetId + "' registration failed: " + (error && error.message ? error.message : error)) } } - registeredPython = peerPython + registeredPython = pythonActive }).catch((error) => { console.log(TAG + ' preset reconcile failed: ' + (error && error.message ? error.message : error)) }) @@ -1870,6 +1922,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio } await reconcile() + if (closed) return console.log(TAG + ' registered ' + live.size + ' preset(s) declaratively (' + [...live.keys()].join(', ') + ')' + (live.size === 0 ? ' — variantPresets is off: the official presets carry Git Bash through the inline tool patch (see the CHANGELOG)' : '') @@ -1932,7 +1985,8 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio console.log(TAG + ' dedupe switch wiring failed: ' + (error && error.message ? error.message : error)) } - ctx.effect(() => () => { for (const off of live.values()) void off() }, 'dsh-gitbash-shell: declarative preset registrations') + // If settings appears after an OFF transition failed closed, retry it. + ctx.inject(['settings'], () => { void reconcile() }) } export async function apply(ctx, config = {}) { @@ -2611,8 +2665,8 @@ export async function apply(ctx, config = {}) { // official tool surface mirrored call-for-call) plus a HIDE on the real pwsh // tool while the pwsh switch is off (route B: the official executor stays in // place, so pwsh is real PowerShell and hiding it is a product choice). The - // variantPresets switch is honored BY OBSERVATION — a variant agent's face - // already has bash, so this patch never fires there. + // Full Windows variants keep the same pwsh-only face as official presets, + // so they receive this same private-executor patch. Minimal stays persistent. if (process.platform === 'win32') { try { mountInlineTools(ctx, { @@ -2661,55 +2715,10 @@ export async function apply(ctx, config = {}) { // With variantPresets ON nothing here runs: the variants stay registered, // so a variant default keeps working as-is. try { - if (ctx.agentPresets && typeof ctx.agentPresets.register === 'function') { - ctx.inject(['settings'], (settingsCtx) => { - void (async () => { - try { - const settings = settingsCtx.settings - if (!settings || typeof settings.update !== 'function') return - // The switch is the whole contract: keep-old-modes ON means a - // variant default is a legitimate choice and stays untouched. - if (liveSettings.variantPresets() === true) return - const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } } - const current = readDefault() - if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return - const target = VARIANT_DEFAULT_SUCCESSORS[current] - if (typeof target !== 'string') return - // The successor must exist in the CURRENT roster: official ids - // renamed once already (code→ptc), and writing an unknown id - // would trade a known failure for a worse one. - const roster = await ctx.agentPresets.list() - if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) { - console.warn(TAG + ' default-preset migration stood down: the deployment default is the variant "' - + current + '" but the successor "' + target + '" is not registered on this host.' - + ' Set the default manually among the OFFICIAL presets, or turn variantPresets on.') - return - } - try { - await settings.update('agent-preset-registry', { selectedDefault: target }) - } catch (error) { - console.warn(TAG + ' default-preset migration write failed (' + (error?.message ?? error) - + '): the deployment default is still the variant "' + current - + '" — NEW sessions would fail with agent-preset/not-found.' - + ' Fix: set an OFFICIAL default in the preset picker, or turn variantPresets on.') - return - } - const after = readDefault() - if (after === target) { - console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target - + '" (variantPresets is off). Old sessions pinned to a variant still need a NEW' - + ' session — the official agent-preset/locked rule blocks a mid-session switch.') - } else { - console.warn(TAG + ' default-preset migration did not stick (read back "' - + (after === undefined ? 'unreadable' : after) + '"). Set the default manually in' - + ' Settings -> agent preset, or turn variantPresets on.') - } - } catch (error) { - console.log(TAG + ' default-preset migration failed: ' + (error?.message ?? error)) - } - })() - }) - } + ctx.inject(['settings'], () => { + // Boot migration is detached: registry.list() may await the Loader boot. + void migrateVariantDefault(ctx, () => liveSettings.variantPresets()) + }) } catch (error) { console.log(TAG + ' default-preset migration wiring failed: ' + (error?.message ?? error)) } @@ -2727,8 +2736,21 @@ export async function apply(ctx, config = {}) { enabled: () => liveSettings.fastctxTools(), createRequireFrom: (base) => createRequire(base ?? import.meta.url), tools: (c) => { try { return c.get('tools') } catch { return undefined } }, - msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())), - echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text), + msysToWin: (p, execution) => { + const dialect = liveSettings.dialect() + if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return p + if (!dialect.virtualMounts) { + return typeof p === 'string' && (MSYS_DRIVE_ROOT.test(p) || MSYS_BARE_DRIVE.test(p)) + ? translateMsysPath(p, null) : p + } + return translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())) + }, + echo: (text, execution) => { + const dialect = liveSettings.dialect() + if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return text + const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null + return env ? msysEcho(text, env) : windowsToMsys(text) + }, log: (m) => console.log(TAG + ' ' + m), }) } catch (error) { diff --git a/src/inline-tools.js b/src/inline-tools.js index db8a7e5..5cbf1d3 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -191,8 +191,25 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc */ export function mountInlineTools(ctx, deps) { const { tools, agents, createScope, pwshEnabled, log } = deps - /** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */ + /** Per-agent mounts, masks and cancellation; `agents` above is the host service. */ const tracked = new Map() + const pending = new Set() + const controller = new AbortController() + let closed = false + const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted + const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() }) + // Loader imports cannot be cancelled, but no continuation may outlive its + // owner. Join our cancelled continuations without waiting on a stuck loader. + const untilDisposed = async (promise, signal) => { + signal.throwIfAborted() + let abort + const cancelled = new Promise((_, reject) => { + abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + }) + try { return await Promise.race([promise, cancelled]) } + finally { signal.removeEventListener('abort', abort) } + } /** Resolved once per process: { executor, executorScope, module }. */ let bashOnce /** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */ @@ -214,58 +231,90 @@ export function mountInlineTools(ctx, deps) { * declarations — the §4k schemastery semantics come for free), so it never * touches the root `ctx.shell` service slot the official pwsh executor * owns. The executor scope lives for the plugin's lifetime and is disposed - * by the effect registered at the bottom of this function. + * by the ownership effect registered before lifecycle wiring. */ const bashOncePromise = async () => { + controller.signal.throwIfAborted() if (bashOnce) return bashOnce - bashOnce = (async () => { - const { GitBashSandboxExecutor } = await import('./shell.js') - if (!GitBashSandboxExecutor) throw new Error('executor class unavailable') - if (!GitBashSandboxExecutor.Config) { - throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)') - } - if (typeof createScope !== 'function') throw new Error('no createScope seam') - const { executor, executorScope } = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope) - const hostModules = {} - for (const [key, name] of HOST_MODULES) { - try { - hostModules[key] = await resolveHostModule(ctx, name) - } catch { hostModules[key] = undefined } + const mounting = (async () => { + let executorScope + try { + const { GitBashSandboxExecutor } = await untilDisposed(import('./shell.js'), controller.signal) + if (!GitBashSandboxExecutor) throw new Error('executor class unavailable') + if (!GitBashSandboxExecutor.Config) { + throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)') + } + if (typeof createScope !== 'function') throw new Error('no createScope seam') + controller.signal.throwIfAborted() + const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope) + executorScope = mounted.executorScope + controller.signal.throwIfAborted() + const hostModules = {} + for (const [key, name] of HOST_MODULES) { + hostModules[key] = await untilDisposed(resolveHostModule(ctx, name), controller.signal) + } + const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal) + const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules }) + return { executor: mounted.executor, executorScope, module } + } catch (error) { + if (executorScope) await executorScope.dispose() + throw error } - const { createBashToolModule } = await import('./bash-tool.js') - const module = createBashToolModule({ executor, log, ...hostModules }) - return { executor, executorScope, module } })() - bashOnce.catch(() => { bashOnce = undefined }) - return bashOnce + bashOnce = mounting + mounting.catch(() => { if (bashOnce === mounting) bashOnce = undefined }) + return mounting } /** Register the own bash tool inside one agent-owned scope. */ const provideBash = async (agent, state) => { - if (state.scope) return + let scope + let retained = false try { - const { executor, module } = await bashOncePromise() + const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal) + if (!isLive(agent, state)) return log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`) - const scope = createScope(agent.ctx) + scope = createScope(agent.ctx) + state.scope = scope // Own the scope BEFORE either asynchronous mount. // Same asynchronous-fiber shape the official module needed: wait for // the apply callback to capture scope.ctx before touching its tools. - if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } } + if (scope.fiber) await untilDisposed(scope.fiber, state.controller.signal) + if (!isLive(agent, state)) return const registry = scope.ctx?.get('tools') - if (!registry || typeof scope.ctx.plugin !== 'function') { - void scope.dispose().catch(() => {}) - return - } + if (!registry || typeof scope.ctx.plugin !== 'function') return const fiber = scope.ctx.plugin(module) - await fiber - state.scope = scope + await untilDisposed(fiber, state.controller.signal) + if (!isLive(agent, state)) return + retained = true log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)') } catch (error) { - log(`own bash tool activation failed for this agent: ${error?.message ?? error}`) + if (isLive(agent, state)) log(`own bash tool activation failed for this agent: ${error?.message ?? error}`) + } finally { + if (scope && !retained) { + if (state.scope === scope) state.scope = undefined + await scope.dispose() + } } } + const startBash = (agent, state) => { + if (state.scope || state.pending || !isLive(agent, state)) return + const task = provideBash(agent, state) + state.pending = task + pending.add(task) + void task.finally(() => { + pending.delete(task) + if (state.pending === task) state.pending = undefined + }).catch((error) => log(`own bash tool cleanup failed: ${error?.message ?? error}`)) + } + const reconcile = (agent, { attempt = 0 } = {}) => { - if (!agent?.ctx) return + if (closed || !agent?.ctx) return + // Undo ONLY our mask before observing the face. schemas() filters a + // restricted-away pwsh, so it cannot tell us what an ON flip must restore. + const previous = tracked.get(agent) + if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined } + if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined } const names = faceNames(agent) // The composed face may not be wired yet at created-time. Retry bounded: // a face with NEITHER tool carries no verdict. Five tries at 250ms, then @@ -275,7 +324,7 @@ export function mountInlineTools(ctx, deps) { timer.unref?.() const state = tracked.get(agent) if (state) state.timer = timer - else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined }) + else tracked.set(agent, { ...newState(), timer }) return } const pwshVisible = deps.pwshEnabled?.() === true @@ -284,7 +333,7 @@ export function mountInlineTools(ctx, deps) { let state = tracked.get(agent) if (state?.timer) { clearTimeout(state.timer); state.timer = undefined } if (!state) { - state = { timer: undefined, scope: undefined, denyPwsh: undefined } + state = newState() tracked.set(agent, state) } // The pwsh face (route B): the host tool is REAL PowerShell now, so the @@ -307,22 +356,46 @@ export function mountInlineTools(ctx, deps) { } } if (needs.needsBash && !state.scope) { - void provideBash(agent, state) + startBash(agent, state) } } - const teardown = (agent) => { + const teardown = async (agent) => { const state = tracked.get(agent) if (!state) return tracked.delete(agent) + state.controller.abort(new Error('inline bash agent disposed')) if (state.timer) clearTimeout(state.timer) - if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } } - if (state.scope) { void state.scope.dispose().catch(() => {}) } + if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined } + await Promise.all([state.scope?.dispose(), state.pending]) } + // Register ownership before the sweep starts asynchronous work. A cancelled + // loader continuation and every per-agent mount must settle before disposal. + ctx.effect(() => async () => { + closed = true + const mounting = bashOnce + controller.abort(new Error('inline bash plugin disposed')) + await Promise.all([ + ...[...tracked.keys()].map(teardown), + ...pending, + mounting?.then(({ executorScope }) => executorScope.dispose(), () => {}), + ]) + tracked.clear() + bashOnce = undefined + }, 'dsh-gitbash-shell: inline tool patching') + try { ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } }) - ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } }) + ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => { + log(`agent bash tool cleanup failed: ${error?.message ?? error}`) + })) + ctx.on('loader/volatile-update', (paths) => { + if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return + for (const agent of tracked.keys()) { + try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) } + } + }) // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row // mounts never fire agent/created. Same shape the proven dsh-ops denial // uses. @@ -346,16 +419,6 @@ export function mountInlineTools(ctx, deps) { return } - ctx.effect(() => () => { - for (const agent of [...tracked.keys()]) teardown(agent) - tracked.clear() - if (bashOnce) { - void Promise.resolve(bashOnce).then(({ executorScope } = {}) => { - try { executorScope?.dispose?.() } catch { /* already gone */ } - }).catch(() => {}) - bashOnce = undefined - } - }, 'dsh-gitbash-shell: inline tool patching') } // The host-package resolution channel lives in bash-tool.js; import it for diff --git a/tests/README.md b/tests/README.md index 15f8f15..80fc50c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,17 +8,32 @@ npm test npm pack --dry-run --ignore-scripts ``` -The `ci` workflow runs the smoke suite and real Cordis lifecycle regressions on -Windows/Linux with Node 22/24. The lockfile supplies Cordis 4.0.4; the new tests -use its real `Context`, `Service`, `plugin`, `isolate`, inject, Config and disposal -contracts, not a mocked service registry. Optional FastCtx payloads and all npm -lifecycle scripts are deliberately excluded from the test dependency install. +The `ci` workflow runs all portable suites on Windows/Linux with Node 22/24. +The lockfile supplies Cordis 4.0.4: executor/lifecycle cases exercise its real +`Context`, `Service`, `plugin`, `isolate`, inject, Config and disposal contracts. +Client, MCP and host-helper cases also use deterministic fixtures; those are not +production end-to-end tests. Actual ToolRuntime integration is checked separately +by the installed-host preview. Optional FastCtx payloads and all npm lifecycle +scripts are deliberately excluded from the test dependency install. + +Additional portable regressions cover snapshot-dependent Hook ordering, retry +and cancellation of client polling, MCP spawn failure/error propagation, dynamic +pwsh visibility, pending tool/preset disposal, jobs withdrawal, fallback schemas, +standing session policy, deployment-default migration, regex data preservation, +sidebar switch updates and the shared dialect gates. No live winget installation +or FastCtx payload is needed. ## Installed-host preview (Windows, Node >= 22.15) Select the **actual host directory** that contains its `package.json` and `node_modules`; do not substitute a global CLI installation for Desktop's Core. -In PowerShell: +In Git Bash: + +```sh +DSH_PREVIEW_HOST_DIR='' npm run test:preview +``` + +Or in PowerShell: ```powershell $env:DSH_PREVIEW_HOST_DIR = '' diff --git a/tests/align-official.mjs b/tests/align-official.mjs index a173570..2374a95 100644 --- a/tests/align-official.mjs +++ b/tests/align-official.mjs @@ -38,9 +38,9 @@ const PRESET_SHA256 = { minimal: '71ef887f43d8a37931ba3b014e5b116f79018af873d06625fb22e280a23c1dfc', } -/** The documented Git Bash delta: these rows are pinned regardless of platform. */ +/** Only minimal's persistent terminal differs from the official shell rows. + * Full variants retain the official platform gates for inline patching. */ const GIT_BASH_ROWS = new Map([ - ['tool-bash', false], ['tool-pwsh', true], ['terminal-bash', false], ['persistent-bash', false], ['terminal-pwsh', true], ['persistent-pwsh', true], ]) @@ -132,17 +132,21 @@ export function flatten(rows, depth = 0) { /** Our rows for one variant id, with the Git Bash delta applied. * The full host probe (dsh 0.2.1 additions) is ON: the alignment target is * the 0.2.1 text, and the 0.1.7-era row set stays locked by smoke.mjs. */ -function oursOf(variant) { +function oursOf(variant, platform) { const kind = KINDS[variant] if (kind === 'minimal') return flatten(minimalPluginsFor()) - return flatten(pluginsFor({ kind, gitBash: true, skillsDir: kind === 'cordis' ? '' : undefined, hostExtras: { timeContext: true, toolSchedule: true } })) + // Exercise BOTH host branches, independently of the machine running this + // audit. This pure composition module has no imports or runtime effects. + const source = readFileSync(new URL('../src/compositions.js', import.meta.url), 'utf8').replace(/^export /gm, '') + const compose = new Function('process', source + '\nreturn pluginsFor')({ platform: platform === 'win32' ? 'win32' : 'linux' }) + return flatten(compose({ kind, gitBash: true, skillsDir: kind === 'cordis' ? '' : undefined, hostExtras: { timeContext: true, toolSchedule: true } })) } const reported = [] /** Compare one variant; pushes human-readable failure lines. */ function compareVariant(variant, officialRows, platform) { - const ours = oursOf(variant) + const ours = oursOf(variant, platform) const label = variant + ' [official ' + platform + ' branch]' if (officialRows.length !== ours.length) { reported.push(`${label}: row count ${ours.length} != official ${officialRows.length}`) @@ -167,8 +171,8 @@ function compareVariant(variant, officialRows, platform) { continue } if (platform === 'win32' && GIT_BASH_ROWS.has(mine.id)) { - // The documented Git Bash delta: bash rows always on, pwsh rows always - // off, on both platforms. + // Exact, row-local exceptions only: full variants receive their private + // bash at agent creation; minimal owns a separate persistent terminal. const expected = GIT_BASH_ROWS.get(mine.id) if (mine.disabled !== expected) reported.push(`${where} (${mine.id}): Git Bash delta lost (disabled ${mine.disabled})`) continue diff --git a/tests/bridge-preview.mjs b/tests/bridge-preview.mjs new file mode 100644 index 0000000..6ec10c6 --- /dev/null +++ b/tests/bridge-preview.mjs @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import * as path from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import { ToolRuntime } from '@deepseek-ai/dsh-tools' +import { McpStdio, mountFastctx } from '../src/fastctx-bridge.js' + +test('preview: MCP failures retain isError through the actual host ToolRuntime', async (t) => { + const root = new Context() + const fibers = [] + const originals = Object.fromEntries(['start', 'listTools', 'callTool', 'close'].map((key) => [key, McpStdio.prototype[key]])) + t.after(async () => { + for (const fiber of fibers.reverse()) await fiber.dispose() + Object.assign(McpStdio.prototype, originals) + }) + for (const module of [SystemPrompt, ToolRuntime]) { + const fiber = root.plugin(module) + fibers.push(fiber) + await fiber + } + McpStdio.prototype.start = async function () { this.dead = false; return this } + McpStdio.prototype.listTools = async function () { + return [{ name: 'inspect_local_file', inputSchema: { type: 'object', properties: {}, additionalProperties: false } }] + } + McpStdio.prototype.callTool = async () => ({ isError: true, content: [{ type: 'text', text: 'fixture file not found' }] }) + McpStdio.prototype.close = async function () { this.dead = true } + function createRequireFrom() { + const req = (name) => name === 'node:path' ? path : name === 'node:fs' ? { existsSync: () => true } : undefined + req.resolve = () => 'C:/fixture/package.json' + return req + } + const patch = root.plugin((ctx) => mountFastctx(ctx, { + platform: 'win32', enabled: () => true, createRequireFrom, tools: (owner) => owner.get('tools'), + msysToWin: (value) => value, echo: (value) => value, log: () => {}, + })) + fibers.push(patch) + await patch + const agent = { ctx: root, session: { header: {} } } + root.emit('agent/created', { agent }) + await new Promise(setImmediate) + const tools = root.get('tools') + assert.ok(tools.schemas(agent).some((schema) => schema.name === 'gb_inspect_local_file')) + const result = await tools.execute({ agent, name: 'gb_inspect_local_file', callId: 'preview-mcp-error', arguments: {}, signal: new AbortController().signal }) + assert.equal(result.isError, true) + assert.match(result.error.message, /gb_inspect_local_file failed: fixture file not found/) + assert.match(result.content[0].text, /fixture file not found/) +}) diff --git a/tests/bridge-regressions.mjs b/tests/bridge-regressions.mjs new file mode 100644 index 0000000..2b381a4 --- /dev/null +++ b/tests/bridge-regressions.mjs @@ -0,0 +1,113 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import * as path from 'node:path' +import { McpStdio, mountFastctx } from '../src/fastctx-bridge.js' + +test('FastCtx: spawn failure rejects initialization and clears the transport', async (t) => { + const client = new McpStdio({ file: join(tmpdir(), `missing-fastctx-${randomUUID()}`), timeoutMs: 1000 }) + t.after(() => client.close()) + await assert.rejects(client.start(), (error) => error.code === 'ENOENT') + assert.equal(client.isDead, true) + assert.equal(client.pending.size, 0) + assert.equal(client.child, undefined) +}) + +async function bridgeFixture(t, result, callbacks = {}) { + const originals = Object.fromEntries(['start', 'listTools', 'callTool', 'close'].map((key) => [key, McpStdio.prototype[key]])) + const definitions = new Map(), listeners = new Map(), cleanups = [], calls = [], instances = [] + McpStdio.prototype.start = async function () { instances.push(this); this.dead = false; return this } + McpStdio.prototype.listTools = async function () { + if (callbacks.listTools) return callbacks.listTools(this) + return [{ name: 'inspect_local_file', inputSchema: { type: 'object', properties: {} } }] + } + McpStdio.prototype.callTool = async function (name, args) { calls.push({ name, args }); return result } + McpStdio.prototype.close = async function () { this.dead = true; callbacks.onClose?.(this) } + t.after(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + Object.assign(McpStdio.prototype, originals) + }) + const registry = { + schemas: () => [], + register(definition) { definitions.set(definition.name, definition); return () => definitions.delete(definition.name) }, + } + const ctx = { on(name, callback) { listeners.set(name, callback) }, effect(callback) { cleanups.push(callback()) } } + function createRequireFrom() { + const req = (name) => name === 'node:path' ? path : name === 'node:fs' ? { existsSync: () => true } : undefined + req.resolve = () => 'C:/fixture/package.json' + return req + } + mountFastctx(ctx, { + platform: 'win32', enabled: () => true, createRequireFrom, tools: () => registry, + msysToWin: (value) => value, echo: (value) => value, log: () => {}, ...callbacks, + }) + const createAgent = () => listeners.get('agent/created')({ agent: {} }) + createAgent() + if (callbacks.concurrentCreation) createAgent() + if (!callbacks.pendingBootstrap) await new Promise(setImmediate) + const definition = definitions.get(`gb_${callbacks.rawName || 'inspect_local_file'}`) + if (!callbacks.pendingBootstrap) assert.ok(definition) + return { definition, definitions, calls, instances, createAgent, dispose: () => Promise.all(cleanups.map((cleanup) => cleanup())) } +} + +test('FastCtx: an MCP tool error throws rather than returning success content', async (t) => { + const { definition } = await bridgeFixture(t, { isError: true, content: [{ type: 'text', text: 'fixture file not found' }] }) + await assert.rejects(definition.execute({ path: '/c/missing.txt' }, { agent: {} }), /gb_inspect_local_file failed: fixture file not found/) +}) + +test('FastCtx: execution context reaches both dialect callbacks', async (t) => { + const execution = { agent: { session: { header: { origin: 'subagent' } } } } + const received = [] + const { definition, calls } = await bridgeFixture(t, { content: [{ type: 'text', text: 'C:/fixture.txt' }] }, { + msysToWin(value, exec) { received.push(exec); return value === '/c/fixture.txt' ? 'C:/fixture.txt' : value }, + echo(value, exec) { received.push(exec); return value.replace('C:/', '/c/') }, + }) + const value = await definition.execute({ path: '/c/fixture.txt' }, execution) + assert.deepEqual(calls[0].args, { path: 'C:/fixture.txt' }) + assert.equal(value.content[0].text, '/c/fixture.txt') + assert.deepEqual(received, [execution, execution]) +}) + +test('FastCtx: concurrent agents share discovery and a lazy connection without orphaning it', async (t) => { + const fixture = await bridgeFixture(t, { content: [{ type: 'text', text: 'fixture' }] }, { concurrentCreation: true }) + assert.equal(fixture.instances.length, 1, 'discovery is single-flight') + await Promise.all([ + fixture.definition.execute({}, { agent: { id: 'a' } }), + fixture.definition.execute({}, { agent: { id: 'b' } }), + ]) + assert.equal(fixture.instances.length, 2, 'both callers share one lazy connection') + await fixture.dispose() + assert.ok(fixture.instances.every((client) => client.isDead)) + await assert.rejects(fixture.definition.execute({}, { agent: {} }), /bridge was stopped/) +}) + +test('FastCtx: preserves upstream #21 real-array glob patterns through execution', async (t) => { + const { definition, calls } = await bridgeFixture(t, { content: [{ type: 'text', text: 'fixture' }] }, { + rawName: 'glob', + listTools: () => [{ name: 'glob', inputSchema: { type: 'object', properties: {} } }], + }) + const patterns = ['**/*.txt', '!tests/**'] + await definition.execute({ pattern: patterns }, { agent: {} }) + assert.deepEqual(calls[0].args.pattern, patterns) + await assert.rejects(definition.execute({ pattern: JSON.stringify(patterns) }, { agent: {} }), /array/i) + assert.equal(calls.length, 1, 'a stringified array must fail before reaching MCP') +}) + +test('FastCtx: disposal cancels discovery and prevents late tool registration', async (t) => { + let rejectDiscovery + const gate = new Promise((_resolve, reject) => { rejectDiscovery = reject }) + const fixture = await bridgeFixture(t, {}, { + pendingBootstrap: true, + listTools: () => gate, + onClose: () => rejectDiscovery(new Error('fixture discovery stopped')), + }) + await new Promise(setImmediate) + await fixture.dispose() + assert.equal(fixture.definitions.size, 0) + assert.ok(fixture.instances.every((client) => client.isDead)) + fixture.createAgent() + await new Promise(setImmediate) + assert.equal(fixture.instances.length, 1) +}) diff --git a/tests/client-regressions.mjs b/tests/client-regressions.mjs new file mode 100644 index 0000000..3ad4108 --- /dev/null +++ b/tests/client-regressions.mjs @@ -0,0 +1,124 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import vm from 'node:vm' +import { readFileSync } from 'node:fs' + +function cardFixture() { + let declaration, hooks = [], effects = [], setters = [], stateIndex = 0 + let status = 'pending', overrides = new Map(), fetchMode = 'none', pollingCalls = 0 + const timers = new Map() + let timerId = 0 + const React = { + Component: function () {}, + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + useState(value) { + hooks.push('state') + const index = stateIndex++ + return [overrides.has(index) ? overrides.get(index) : (typeof value === 'function' ? value() : value), (next) => setters.push({ index, value: next })] + }, + useEffect(callback) { hooks.push('effect'); effects.push(callback) }, + } + const sandbox = { + window: { __ModuleLoader__: { load: (value) => { declaration = value } } }, + console: { ...console, warn: () => {} }, + async fetch(url) { + if (String(url).includes('?job=1')) { + pollingCalls++ + if (fetchMode === 'network-error') throw new Error('fixture transient network failure') + if (fetchMode === 'http-error') return { ok: false, status: 503, json: async () => ({}) } + return { ok: true, json: async () => ({ job: { finished: true } }) } + } + return { ok: false } + }, + setTimeout(callback) { const id = ++timerId; timers.set(id, callback); return id }, + clearTimeout(id) { timers.delete(id) }, + } + vm.runInNewContext(readFileSync(new URL('../src/client.js', import.meta.url), 'utf8'), sandbox) + const mod = declaration.factory((name) => name === 'react' ? React : {}) + const registrations = [] + const form = { getSnapshot: () => ({ status, value: {} }), subscribe: () => () => {}, set: () => Promise.resolve(true) } + const forms = { get: () => form } + const ctx = { + locale: { register: () => () => {} }, configForms: forms, + get: (name) => name === 'configForms' ? forms : undefined, + inject(names, callback) { if (names[0] === 'configForms') callback(ctx) }, + effect: (callback) => callback(), + slots: { + inject: (_name, callback) => callback(), + register(options, component) { registrations.push({ options, component }); return () => {} }, + }, + } + mod.apply(ctx) + const wrapper = registrations.find((row) => row.options.name === 'plugins.bundle.config').component + const locale = wrapper({ scope: form, ctx }) + const card = locale.type(locale.props).children[0] + return { + setStatus: (value) => { status = value }, + setJob: (job) => { overrides = new Map([[6, job]]) }, + setFetchMode: (mode) => { fetchMode = mode }, + render() { + hooks = []; effects = []; setters = []; stateIndex = 0 + card.type(card.props) + return { hooks: [...hooks], effects: [...effects] } + }, + async runTimer() { + const [id, callback] = timers.entries().next().value + timers.delete(id) + callback() + await new Promise(setImmediate) + }, + get timers() { return timers }, + get pollingCalls() { return pollingCalls }, + get setters() { return setters }, + } +} + +test('client: pending, ready and unavailable snapshots call the same hooks', () => { + const fixture = cardFixture() + const pending = fixture.render() + fixture.setStatus('ready') + const ready = fixture.render() + fixture.setStatus('unavailable') + const unavailable = fixture.render() + assert.deepEqual(pending.hooks, ready.hooks) + assert.deepEqual(unavailable.hooks, ready.hooks) + assert.equal(ready.hooks.filter((kind) => kind === 'effect').length, 5) + assert.equal(pending.effects.at(-2)(), undefined) + assert.equal(pending.effects.at(-1)(), undefined) + assert.equal(fixture.timers.size, 0) +}) + +for (const failure of ['network-error', 'http-error']) { + test(`client: job polling retries after ${failure} and cancels on cleanup`, async () => { + const fixture = cardFixture() + fixture.setStatus('ready') + fixture.setJob({ finished: false, done: 0, total: 1, results: [] }) + fixture.setFetchMode(failure) + const render = fixture.render() + const cleanup = render.effects.at(-1)() + assert.equal(fixture.timers.size, 1) + await fixture.runTimer() + assert.equal(fixture.pollingCalls, 1) + assert.equal(fixture.timers.size, 1, 'a failed fetch must leave a retry timer') + assert.equal(fixture.setters.filter((row) => row.index === 6).length, 0) + fixture.setFetchMode('success') + await fixture.runTimer() + assert.equal(fixture.pollingCalls, 2) + assert.equal(fixture.setters.filter((row) => row.index === 6).length, 1) + cleanup() + assert.equal(fixture.timers.size, 0) + }) +} + +test('client: cleanup prevents a failed in-flight poll from scheduling a retry', async () => { + const fixture = cardFixture() + fixture.setStatus('ready') + fixture.setJob({ finished: false, done: 0, total: 1, results: [] }) + fixture.setFetchMode('network-error') + const cleanup = fixture.render().effects.at(-1)() + const polling = fixture.runTimer() + cleanup() + await polling + assert.equal(fixture.timers.size, 0) + assert.equal(fixture.setters.filter((row) => row.index === 6).length, 0) +}) diff --git a/tests/host-preview.mjs b/tests/host-preview.mjs new file mode 100644 index 0000000..ab1ad12 --- /dev/null +++ b/tests/host-preview.mjs @@ -0,0 +1,123 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { Context } from '@deepseek-ai/cordis' +import { AgentPresetRegistry } from '@deepseek-ai/dsh-agent-preset-registry' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '@deepseek-ai/dsh-pwsh-sandbox' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import { ToolRuntime } from '@deepseek-ai/dsh-tools' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as toolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import { pluginsFor } from '../src/compositions.js' +import { mountInlineTools } from '../src/inline-tools.js' +import { _internal as x } from '../src/index.js' + +function createScope(parent) { + let childCtx + const fiber = parent.plugin((ctx) => { childCtx = ctx }) + return { fiber, get ctx() { return childCtx }, dispose: () => fiber.dispose() } +} +const tick = () => new Promise((resolve) => setImmediate(resolve)) + +// Actual Core services, not a production profile. Each variant's real shell +// rows mount into a fresh Context, then ToolRuntime dispatches the inline bash. +for (const kind of ['standard', 'ptc', 'cordis']) { + test(`host preview: ${kind} variant executes private Git Bash while root PowerShell survives`, { timeout: 30000 }, async (t) => { + assert.equal(process.platform, 'win32') + const root = new Context(), cleanups = [], logs = [] + const own = (off) => { cleanups.push(off); return off } + t.after(async () => { for (const off of cleanups.reverse()) await off() }) + async function mount(module, config) { + const fiber = root.plugin(module, config) + own(() => fiber.dispose()); await fiber + return fiber + } + const cwd = process.cwd(), policy = { mode: 'danger-full-access', workspaceRoot: cwd } + for (const [key, value] of Object.entries({ + sandboxPolicy: { defaultMode: policy.mode, resolve: () => policy }, + sandbox: { confine() { throw new Error('preview only uses full access') } }, + workingDirectory: { ensure: async () => cwd }, + loader: { internal: { import: (name) => import(name) } }, + })) own(root.provide(key, value)) + for (const module of [SystemPrompt, LocalSubprocessRuntime, SandboxPwshExecutor, ToolRuntime, shellEnv]) await mount(module) + const official = root.reflect.store[root[Context.isolate].shell] + const rows = pluginsFor({ kind, gitBash: true, hostExtras: { timeContext: true, toolSchedule: true } }) + for (const row of rows.filter((row) => ['tool-bash', 'tool-pwsh'].includes(row.id) && row.disabled !== true)) { + await mount(row.id === 'tool-bash' ? toolBash : toolPwsh) + } + const tools = root.get('tools') + assert.deepEqual(tools.schemas().map((schema) => schema.name), ['pwsh']) + let agentParent + await mount({ inject: ['tools'], apply(ctx) { agentParent = ctx } }) + const agent = { session: { header: {} } }, agentScope = createAgentScope(agentParent, agent) + agent.ctx = agentScope.ctx; own(() => agentScope.dispose()) + let ready, failed + const settled = new Promise((resolve, reject) => { ready = resolve; failed = reject }) + const timer = setTimeout(() => failed(new Error('inline patch did not settle: ' + logs.join('\n'))), 15000) + own(() => clearTimeout(timer)) + const patch = await mount((ctx) => mountInlineTools(ctx, { + tools: (c) => c.get('tools'), agents: () => ({ list: () => [agent] }), createScope, + pwshEnabled: () => true, + log(message) { + logs.push(message) + if (message.includes('activation failed')) failed(new Error(message)) + if (message.includes('own bash tool activated')) ready() + }, + })) + await settled; clearTimeout(timer) + const result = await tools.execute({ + agent, name: 'bash', callId: `variant-${kind}`, signal: new AbortController().signal, + arguments: { description: 'Verify private variant executor', command: 'uname -s; printf "VARIANT_GITBASH_OK\\n"', workdir: cwd }, + }) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.equal(result.value.exitCode, 0) + const text = result.content.map((block) => block.text ?? '').join('\n') + assert.match(text, /MINGW(?:32|64)_NT-/) + assert.match(text, /VARIANT_GITBASH_OK/) + assert.equal(root.reflect.store[root[Context.isolate].shell], official) + await patch.dispose() + assert.ok(!tools.schemas(agent).some((schema) => schema.name === 'bash')) + assert.ok(tools.schemas(agent).some((schema) => schema.name === 'pwsh')) + assert.equal(root.reflect.store[root[Context.isolate].shell], official) + }) +} + +test('host preview: real Core registry waits for activation then retires a registration during owner dispose', { timeout: 10000 }, async (t) => { + const root = new Context(), cleanups = [] + t.after(async () => { for (const off of cleanups.reverse()) await off() }) + cleanups.push(root.provide('loader', { await: async () => {} })) + cleanups.push(root.provide('sessionProjections', { register: () => () => {} })) + const registryFiber = root.plugin(AgentPresetRegistry, { default: 'standard' }) + cleanups.push(() => registryFiber.dispose()); await registryFiber + const registry = root.get('agentPresets') + // Only activation is controlled: register()/unregister() are the installed + // Core implementations. No Loader tree, filesystem or profile is mounted. + let release, entered + const activation = new Promise((resolve) => { release = resolve }) + const starting = new Promise((resolve) => { entered = resolve }) + registry.activate = async () => { entered(); await activation } + const src = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') + const body = src.slice(src.indexOf('async function runDeclarativeEra('), src.indexOf('\nexport async function apply(')) + const run = new Function('cleanupLegacyTrees', 'resolveSkillsDir', 'probeHostExtras', 'effectivePresetIds', 'registerVariant', 'migrateVariantDefault', 'TAG', 'PEER_CAPABILITY', 'peerFact', 'PEER_PYTHON_FIELD', 'peerBackend', 'pythonBackendActive', 'PEER_PYTHON_BACKEND_FIELD', body + '\nreturn runDeclarativeEra')( + () => {}, async () => undefined, async () => ({}), x.effectivePresetIds, + (ctx, id) => ctx.agentPresets.register({ id, plugins: [] }), async () => true, + '[host-preview]', x.PEER_CAPABILITY, x.peerFact, x.PEER_PYTHON_FIELD, x.peerBackend, x.pythonBackendActive, x.PEER_PYTHON_BACKEND_FIELD, + ) + let ctx, variants = false + const owner = root.plugin({ inject: ['agentPresets'], apply(value) { ctx = value } }) + cleanups.push(() => owner.dispose()); await owner + await run(ctx, ['standard-gitbash'], () => false, () => ({ ok: false }), () => variants) + variants = true; ctx.emit('loader/volatile-update', [['variantPresets']]); await starting + assert.equal(registry.definitions.has('standard-gitbash'), true) + let disposed = false + const done = owner.dispose().then(() => { disposed = true }) + await tick(); assert.equal(disposed, false) + release(); await done + assert.equal(registry.definitions.has('standard-gitbash'), false) + const unregister = await registry.register({ id: 'standard-gitbash', plugins: [] }) + await unregister() + assert.equal(registry.definitions.size, 0, 'same id is reusable after awaited cleanup') +}) diff --git a/tests/host-regressions.mjs b/tests/host-regressions.mjs new file mode 100644 index 0000000..ce477bb --- /dev/null +++ b/tests/host-regressions.mjs @@ -0,0 +1,233 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { _internal as x } from '../src/index.js' +import { inlineNeeds } from '../src/inline-tools.js' + +const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') +const tick = () => new Promise((resolve) => setImmediate(resolve)) +const flush = async () => { for (let i = 0; i < 4; i++) await tick() } +const deferred = () => { + let resolve + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +const migration = source.slice(source.indexOf('async function migrateVariantDefault('), source.indexOf('\nasync function registerVariant(')) +const declarative = source.slice(source.indexOf('async function runDeclarativeEra('), source.indexOf('\nexport async function apply(')) + +function runtime(register) { + return new Function('cleanupLegacyTrees', 'resolveSkillsDir', 'probeHostExtras', 'effectivePresetIds', 'registerVariant', 'TAG', 'PEER_CAPABILITY', 'peerFact', 'PEER_PYTHON_FIELD', 'peerBackend', 'pythonBackendActive', 'PEER_PYTHON_BACKEND_FIELD', 'PRESET_IDS', 'VARIANT_DEFAULT_SUCCESSORS', migration + declarative + '\nreturn runDeclarativeEra')( + () => {}, async () => undefined, async () => ({}), x.effectivePresetIds, register, + '[host-regression]', x.PEER_CAPABILITY, x.peerFact, x.PEER_PYTHON_FIELD, x.peerBackend, + x.pythonBackendActive, x.PEER_PYTHON_BACKEND_FIELD, x.PRESET_IDS, x.VARIANT_DEFAULT_SUCCESSORS, + ) +} + +async function fixture(t, { enabled = false, defaultId = 'standard', registerGate, retireGate, migrate = true, failRetireOnce = false } = {}) { + const effects = [], listeners = new Map(), injects = new Map(), definitions = new Set(), timeline = [] + let variants = enabled, selected = defaultId, retirementFailures = failRetireOnce ? 1 : 0 + const settings = { + async update(namespace, patch) { + assert.equal(namespace, 'agent-preset-registry') + timeline.push('default:' + patch.selectedDefault) + if (migrate) selected = patch.selectedDefault + }, + } + const ctx = { + agentPresets: { get defaultId() { return selected }, async list() { return [{ id: 'standard' }, { id: 'ptc' }, { id: 'cordis' }, { id: 'minimal' }] } }, + get(name) { return name === 'settings' ? settings : undefined }, + effect(fn) { const off = fn(); effects.push(off); return off }, + on(name, fn) { listeners.set(name, fn) }, + inject(keys, fn) { injects.set(keys[0], fn) }, + } + const register = async (_ctx, id, options) => { + assert.equal(definitions.has(id), false, 'retirement must precede a duplicate registration') + definitions.add(id); timeline.push('register:' + id + ':' + options.pythonActive) + if (registerGate) await registerGate.promise + return async () => { + if (retirementFailures-- > 0) throw new Error('fixture retirement failure') + definitions.delete(id); timeline.push('off:' + id) + if (retireGate) await retireGate.promise + timeline.push('retired:' + id) + } + } + let disposePromise + const dispose = () => disposePromise ??= (async () => { for (const off of [...effects].reverse()) await off() })() + t.after(dispose) + await runtime(register)(ctx, ['standard-gitbash'], () => false, () => ({ ok: false }), () => variants) + return { + ctx, definitions, timeline, dispose, injects, + get defaultId() { return selected }, + update(value) { variants = value; listeners.get('loader/volatile-update')([['variantPresets']]) }, + } +} + +test('host: all full Win32 variants use the inline private-executor face; minimal stays persistent', () => { + const compositions = readFileSync(new URL('../src/compositions.js', import.meta.url), 'utf8').replace(/^export /gm, '') + const { pluginsFor, minimalPluginsFor } = new Function('process', compositions + '\nreturn { pluginsFor, minimalPluginsFor }')({ platform: 'win32' }) + for (const kind of ['standard', 'ptc', 'cordis']) { + const rows = pluginsFor({ kind, gitBash: true }) + assert.equal(rows.find((row) => row.id === 'tool-bash').disabled, true) + assert.notEqual(rows.find((row) => row.id === 'tool-pwsh').disabled, true) + assert.equal(inlineNeeds(['pwsh']).needsBash, true) + } + const persistent = minimalPluginsFor().find((row) => row.id === 'persistent-shell').config + assert.notEqual(persistent.find((row) => row.id === 'persistent-bash').disabled, true) + assert.equal(persistent.find((row) => row.id === 'persistent-pwsh').disabled, true) +}) + +test('host: dispose drains pending registration and awaits its late unregister', async (t) => { + const registering = deferred(), retiring = deferred() + const f = await fixture(t, { registerGate: registering, retireGate: retiring }) + f.update(true); await flush() + assert.deepEqual([...f.definitions], ['standard-gitbash']) + let disposed = false + const done = f.dispose().then(() => { disposed = true }) + await flush(); assert.equal(disposed, false) + registering.resolve(); await flush() + assert.equal(f.definitions.size, 0) + assert.equal(disposed, false, 'unregister is also asynchronous') + retiring.resolve(); await done + f.update(true); await flush() + assert.equal(f.definitions.size, 0, 'closed reconciler cannot register again') +}) + +test('host: dynamic OFF migrates deployment default before retiring the variant', async (t) => { + const f = await fixture(t, { enabled: true, defaultId: 'standard-gitbash' }) + f.update(false); await flush() + assert.equal(f.defaultId, 'standard') + assert.equal(f.definitions.size, 0) + assert.ok(f.timeline.indexOf('default:standard') < f.timeline.indexOf('off:standard-gitbash')) + assert.equal(f.timeline.filter((row) => row.startsWith('default:')).length, 1) +}) + +test('host: unverifiable default migration does not retire its live variant', async (t) => { + const f = await fixture(t, { enabled: true, defaultId: 'standard-gitbash', migrate: false }) + f.update(false); await flush() + assert.equal(f.defaultId, 'standard-gitbash') + assert.equal(f.definitions.has('standard-gitbash'), true) +}) + +test('host: backend rebuild awaits retirement before registering the same id', async (t) => { + const gate = deferred(), f = await fixture(t, { enabled: true, retireGate: gate }) + f.injects.get(x.PEER_CAPABILITY)({ [x.PEER_CAPABILITY]: { pythonRuntime: true, pythonBackend: 'python' }, effect(fn) { return fn() } }) + await flush() + assert.equal(f.timeline.filter((entry) => entry.startsWith('register:')).length, 1) + gate.resolve(); await flush() + assert.equal(f.timeline.filter((entry) => entry.startsWith('register:')).length, 2) + assert.ok(f.timeline.indexOf('retired:standard-gitbash') < f.timeline.indexOf('register:standard-gitbash:true')) +}) + +test('host: a failed retirement prevents replacement and remains retryable', async (t) => { + const f = await fixture(t, { enabled: true, failRetireOnce: true }) + f.injects.get(x.PEER_CAPABILITY)({ [x.PEER_CAPABILITY]: { pythonRuntime: true, pythonBackend: 'python' }, effect(fn) { return fn() } }) + await flush() + assert.equal(f.timeline.filter((row) => row.startsWith('register:')).length, 1) + assert.equal(f.definitions.has('standard-gitbash'), true) + f.update(true); await flush() + assert.equal(f.timeline.filter((row) => row.startsWith('register:')).length, 2) +}) + +test('host: disposal attempts every unregister even when one fails', async () => { + const effects = [], retired = [] + const ctx = { effect(fn) { effects.push(fn()) }, inject() {}, on() {} } + const register = async (_ctx, id) => async () => { + retired.push(id) + if (id === 'standard-gitbash') throw new Error('fixture retirement failure') + } + await runtime(register)(ctx, ['standard-gitbash', 'cordis-gitbash'], () => false, () => ({ ok: false }), () => true) + await assert.rejects(effects[0](), /Git Bash preset cleanup failed/) + assert.deepEqual(retired, ['standard-gitbash', 'cordis-gitbash']) +}) + +test('host: keyword-led regexes remain data, and ambiguous slash aborts the whole rewrite', () => { + const re = "/['/c/x']/" + for (const keyword of ['return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof']) { + const code = `const p = '/c/a'; ${keyword} ${re}` + assert.equal(x.rewriteCodePaths(code, null), `const p = 'C:/a'; ${keyword} ${re}`) + } + for (const code of [ + `const p='/c/a'; if (ready) ${re}.test(value)`, + `const p='/c/a'; {} ${re}.test(value)`, + `const p='/c/a'; return /unterminated`, + ]) assert.equal(x.rewriteCodePaths(code, null), code) + const code = "(() => { return /['/c/x']/ })()" + assert.equal(x.rewriteCodePaths(code, null), code) + assert.equal(eval(code).test('C'), false) + assert.equal(x.rewriteCodePaths("const a='/c/a'; const b='~/b'", { home: "C:/user's" }), "const a='/c/a'; const b='~/b'", 'unsafe translated quote aborts all edits') +}) + +function sidebar(t, enabled = true, initial = 'previous-shell') { + let value = initial, adopt = enabled + const listeners = new Map(), effects = [], updates = [] + const settings = { describe: () => [{ ns: 'dsh-better-sidebar', value: { terminalShell: value } }], async update(_ns, patch) { value = patch.terminalShell; updates.push(value) } } + const ctx = { get: () => settings, effect(fn) { effects.push(fn()) }, on(name, fn) { listeners.set(name, fn) } } + x.adoptSidebarShell(ctx, 'Q:/Git/bin/bash.exe', () => adopt) + let disposed + const dispose = () => disposed ??= (async () => { for (const off of [...effects].reverse()) await off() })() + t.after(dispose) + return { get value() { return value }, updates, dispose, manual(next) { value = next }, toggle(next) { adopt = next; listeners.get('loader/volatile-update')([['adoptSidebar']]) } } +} + +test('host: sidebar volatile OFF restores previous, ON adopts again, and user choice survives OFF', async (t) => { + const f = sidebar(t); await flush() + assert.equal(f.value, 'Q:/Git/bin/bash.exe') + f.toggle(false); await flush(); assert.equal(f.value, 'previous-shell') + f.toggle(true); await flush(); assert.equal(f.value, 'Q:/Git/bin/bash.exe') + f.manual('user-shell'); f.toggle(false); await flush() + assert.equal(f.value, 'user-shell') + assert.deepEqual(f.updates, ['Q:/Git/bin/bash.exe', 'previous-shell', 'Q:/Git/bin/bash.exe']) +}) + +test('host: a startup same-value sidebar is not ours to restore on OFF or dispose', async (t) => { + const f = sidebar(t, true, 'Q:/Git/bin/bash.exe'); await flush() + f.toggle(false); await flush() + await f.dispose() + assert.equal(f.value, 'Q:/Git/bin/bash.exe') + assert.deepEqual(f.updates, [], 'no takeover means no write and no restoration') +}) + +test('host: manual sidebar selection while adopted survives owner dispose', async (t) => { + const f = sidebar(t); await flush() + f.manual('user-shell') + await f.dispose() + assert.equal(f.value, 'user-shell') + assert.deepEqual(f.updates, ['Q:/Git/bin/bash.exe']) +}) + +test('host: sidebar starting OFF can adopt after a volatile ON', async (t) => { + const f = sidebar(t, false); await flush() + assert.equal(f.value, 'previous-shell') + f.toggle(true); await flush(); assert.equal(f.value, 'Q:/Git/bin/bash.exe') +}) + +test('host: FastCtx callbacks share root/delegate gates and virtual mounts are independently optional', () => { + const start = source.indexOf('mountFastctx(ctx, {') + const body = source.slice(start, source.indexOf('\n })', start) + '\n })'.length) + .replace(/import\.meta\.url/g, JSON.stringify(import.meta.url)) + let callbacks, dialect, probes = 0 + new Function('mountFastctx', 'ctx', 'process', 'liveSettings', 'createRequire', 'translateMsysPath', 'buildTranslateEnv', 'readConfiguredBashPath', 'MSYS_DRIVE_ROOT', 'MSYS_BARE_DRIVE', 'dialectApplies', 'msysEcho', 'windowsToMsys', 'TAG', body)( + (_ctx, deps) => { callbacks = deps }, {}, { platform: 'win32' }, { dialect: () => dialect }, () => {}, x.translateMsysPath, + () => { probes++; return { tmpDir: 'C:/Temp', home: 'C:/Users/u', gitRoot: 'Q:/Git' } }, () => '', /^\/([A-Za-z])\/(.*)$/, /^\/([A-Za-z])$/, x.dialectApplies, x.msysEcho, x.windowsToMsys, '[fixture]', + ) + const root = { agent: { session: { header: {} } } } + const child = { agent: { session: { header: { origin: 'subagent' } } } } + const nested = { agent: { session: { header: { delegationDepth: 2 } } } } + dialect = { posixPaths: true, virtualMounts: true, subagentDialect: false } + for (const exec of [child, nested]) { + assert.equal(callbacks.msysToWin('/c/a', exec), '/c/a') + assert.equal(callbacks.echo('C:/Temp/a', exec), 'C:/Temp/a') + } + assert.equal(probes, 0) + assert.equal(callbacks.msysToWin('/tmp/a', root), 'C:/Temp/a') + assert.equal(callbacks.echo('C:/Temp/a', root), '/tmp/a') + dialect = { ...dialect, posixPaths: false } + assert.equal(callbacks.msysToWin('/c/a', root), '/c/a') + assert.equal(callbacks.echo('C:/a', root), 'C:/a') + dialect = { ...dialect, posixPaths: true, virtualMounts: false, subagentDialect: true } + for (const exec of [root, child, nested]) { + assert.equal(callbacks.msysToWin('/c/a', exec), 'C:/a') + for (const path of ['/tmp/a', '/usr/bin', '/dev/null', '~/a']) assert.equal(callbacks.msysToWin(path, exec), path) + assert.equal(callbacks.echo('C:/Temp/a', exec), '/c/Temp/a') + } +}) diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs new file mode 100644 index 0000000..eafae3e --- /dev/null +++ b/tests/runtime-preview.mjs @@ -0,0 +1,262 @@ +// Actual installed Core/Cordis contracts, isolated Contexts, and memory-only +// subprocesses. No production profile, filesystem writes, or model requests. +import test from 'node:test' +import assert from 'node:assert/strict' +import { Context } from '@deepseek-ai/cordis' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import { mountInlineTools, mountPrivateExecutor } from '../src/inline-tools.js' +import { createBashToolModule } from '../src/bash-tool.js' +import { GitBashSandboxExecutor } from '../src/shell.js' + +const deferred = () => { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} +async function bounded(promise, label, timeoutMs = 3000) { + let timer + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`runtime preview timed out: ${label}`)), timeoutMs) + })]) + } finally { clearTimeout(timer) } +} +function createScope(parent) { + let ctx + const fiber = parent.plugin(c => { ctx = c }) + return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } +} + +async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime } = {}) { + const root = new Context() + const cleanups = [] + const logs = [] + const entered = deferred() + const release = deferred() + const activated = deferred() + const failed = deferred() + const agentScopes = [] + const official = { kind: 'official-pwsh' } + const policy = { mode: 'danger-full-access', workspaceRoot: process.cwd() } + const readFrom = () => ({ text: 'runtime-preview-ok', nextOffset: 18, lossy: false }) + for (const [name, value] of Object.entries({ + shell: official, + subprocess: subprocessRuntime ?? { spawn() { return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: { stdout: { readFrom }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() {} } } }, + sandbox: {}, + sandboxPolicy: { defaultMode: policy.mode, resolve: request => request?.session?.header.policy ?? policy }, + workingDirectory: { ensure: async () => process.cwd() }, + loader: { internal: { async import(name) { + if (pauseModules && name === '@deepseek-ai/dsh-tools') { entered.resolve(); await release.promise } + return import(name) + } } }, + })) cleanups.push(root.provide(name, value)) + const mount = async module => { + const fiber = root.plugin(module) + cleanups.push(() => fiber.dispose()) + await fiber + return fiber + } + for (const module of [SystemPrompt, ToolRuntime, shellEnv]) await mount(module) + let agentParent + await mount({ inject: ['tools'], apply(ctx) { + agentParent = ctx + for (const name of ['pwsh', ...(existingBash ? ['bash'] : [])]) { + ctx.tools.register(defineTool({ name, description: name, parameters: {}, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, execute: () => name })) + } + } }) + const agent = { id: 'runtime-preview-agent', session: { header: {} } } + const agentScope = createAgentScope(agentParent, agent) + agent.ctx = agentScope.ctx + cleanups.push(() => agentScope.dispose()) + const deps = { + tools: ctx => ctx.get('tools'), agents: () => ({ list: () => [agent] }), + pwshEnabled: () => pwshVisible, + log(message) { + logs.push(message) + if (message.includes('own bash tool activated')) activated.resolve() + if (message.includes('activation failed')) failed.resolve() + }, + createScope(parent) { + const scope = createScope(parent) + if (parent === agent.ctx) { + agentScopes.push(scope) + if (pauseAgentScope) { + const fiber = scope.fiber + scope.fiber = Promise.resolve(fiber).then(async () => { entered.resolve(); await release.promise }) + } + if (failAgentMount) { + return { fiber: scope.fiber, get ctx() { return { get: name => scope.ctx.get(name), plugin() { throw new Error('isolated agent plugin mount failure') } } }, dispose: () => scope.dispose() } + } + } + return scope + }, + } + const patch = await mount(ctx => mountInlineTools(ctx, deps)) + const tools = root.get('tools') + t.after(async () => { + release.resolve() + for (const cleanup of cleanups.reverse()) await cleanup() + }) + return { + root, agent, tools, patch, official, logs, entered, release, activated, failed, agentScopes, deps, + names: () => tools.schemas(agent).map(schema => schema.name), + set: value => { pwshVisible = value; root.emit('loader/volatile-update', [['pwshTool']]) }, + allowAgentMount: () => { failAgentMount = false }, + execute: (name, args = {}) => tools.execute({ agent, name, callId: `${name}-runtime-preview`, arguments: args, signal: new AbortController().signal }), + remount: () => mount(ctx => mountInlineTools(ctx, deps)), + } +} + +test('runtime preview: live pwsh schema and dispatch follow both switch directions', { timeout: 10000 }, async t => { + const f = await fixture(t, { existingBash: true }) + assert.deepEqual(f.names(), ['bash']) + f.set(true) + assert.ok(f.names().includes('pwsh')) + assert.equal((await f.execute('pwsh')).value, 'pwsh') + f.set(false) + assert.ok(!f.names().includes('pwsh')) + assert.equal((await f.execute('pwsh')).isError, true) + f.set(true) + assert.equal((await f.execute('pwsh')).isError, false) + assert.equal(f.root.get('shell'), f.official) + await f.patch.dispose() + assert.ok(f.names().includes('pwsh')) +}) + +test('runtime preview: plugin disposal cancels a delayed host import and cannot leave a ghost bash', { timeout: 10000 }, async t => { + const f = await fixture(t, { pauseModules: true }) + await bounded(f.entered.promise, 'host module import entered') + await bounded(f.patch.dispose(), 'plugin disposal with the loader still blocked') + assert.deepEqual(f.names(), ['pwsh']) + f.release.resolve() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(f.names(), ['pwsh']) + assert.ok(!f.logs.some(message => message.includes('own bash tool activated'))) + const replacement = await f.remount() + await bounded(f.activated.promise, 'replacement bash activation') + const result = await f.execute('bash', { command: 'memory-only command', description: 'Verify replacement bash tool' }) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.equal(result.value.exitCode, 0) + assert.equal(f.root.get('shell'), f.official) + await replacement.dispose() + assert.deepEqual(f.names(), ['pwsh']) +}) + +test('runtime preview: disposal owns and joins a scope whose agent mount is still pending', { timeout: 10000 }, async t => { + const f = await fixture(t, { pauseAgentScope: true }) + await bounded(f.entered.promise, 'per-agent scope entered') + assert.equal(f.agentScopes.length, 1) + f.set(true) + f.set(false) + assert.equal(f.agentScopes.length, 1, 'volatile updates do not duplicate an in-flight mount') + await bounded(f.patch.dispose(), 'disposal during scope mount') + f.release.resolve() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(f.names(), ['pwsh']) + assert.equal(f.agentScopes[0].ctx.fiber.state, 4, 'agent-owned scope is disposed') +}) + +test('runtime preview: agent disposal cancels its delayed mount while preserving the plugin executor', { timeout: 10000 }, async t => { + const f = await fixture(t, { pauseAgentScope: true }) + await bounded(f.entered.promise, 'agent mount entered') + await bounded(f.root.parallel('agent/disposed', { agent: f.agent }), 'agent disposal listener') + f.release.resolve() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(f.names(), ['pwsh']) + assert.equal(f.agentScopes[0].ctx.fiber.state, 4) + assert.equal(f.root.get('shell'), f.official) +}) + +test('runtime preview: a failed per-agent plugin mount releases its scope and can retry', { timeout: 10000 }, async t => { + const f = await fixture(t, { failAgentMount: true }) + await bounded(f.failed.promise, 'agent mount failure') + await new Promise(resolve => setImmediate(resolve)) + assert.equal(f.agentScopes[0].ctx.fiber.state, 4) + assert.ok(!f.names().includes('bash')) + f.allowAgentMount() + f.set(true) + await bounded(f.activated.promise, 'retried agent mount') + assert.equal(f.agentScopes.length, 2) + assert.ok(f.names().includes('bash')) +}) + +test('runtime preview: actual ToolRuntime accepts and executes the recursive plain fallback', { timeout: 10000 }, async t => { + const f = await fixture(t, { existingBash: true, pwshVisible: true }) + await f.patch.dispose() + const child = createScope(f.agent.ctx) + t.after(() => child.dispose()) + await child.fiber + const requests = [] + const executor = { + sandboxMode: 'danger-full-access', + resolve: request => { requests.push(request); return { ...request, workdir: process.cwd(), timeoutMs: 100 } }, + execute: async spec => ({ result: async () => ({ exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: spec.timeoutMs, + stdout: { text: 'plain-fallback-ok', truncated: false }, stderr: { text: '', truncated: false }, sandbox: { mode: spec.sandboxPolicy.mode, denied: false }, + }) }), + } + const module = createBashToolModule({ executor, log() {} }) + await child.ctx.plugin(module) + f.agent.session.header.policy = { mode: 'read-only', workspaceRoot: 'D:/child-workspace' } + const result = await f.execute('bash', { command: 'memory-only command', description: 'Verify plain fallback execution' }) + assert.equal(result.isError, false, JSON.stringify(result)) + assert.equal(result.value.stdout.text, 'plain-fallback-ok') + assert.deepEqual(requests[0].sandboxPolicy, f.agent.session.header.policy) + assert.equal(result.value.sandbox.mode, 'read-only') + const escalation = await f.execute('bash', { command: 'memory-only command', description: 'Reject missing approval helper', sandbox_permissions: 'danger-full-access', justification: 'Request broader access.' }) + assert.equal(escalation.isError, true) + assert.match(escalation.error.message, /no sandbox helper/) +}) + +test('runtime preview: jobs unload/reload swaps between job-backed and foreground registrations', { timeout: 10000 }, async t => { + const f = await fixture(t) + await bounded(f.activated.promise, 'own bash activation') + assert.equal(f.tools.get('bash', f.agent).parameters.properties.run_in_background, undefined) + const removeJobs = f.root.provide('jobs', {}) + try { + await new Promise(resolve => setImmediate(resolve)) + assert.ok(f.tools.get('bash', f.agent).parameters.properties.run_in_background) + } finally { await removeJobs() } + assert.ok(f.names().includes('bash')) + assert.equal(f.tools.get('bash', f.agent).parameters.properties.run_in_background, undefined) + const result = await f.execute('bash', { command: 'memory-only command', description: 'Verify foreground after jobs unload' }) + assert.equal(result.isError, false, JSON.stringify(result)) +}) + +test('runtime preview: private execution retains deadlines, background no-expiry, cancellation, and provider failures', { timeout: 10000 }, async t => { + let rejectSpawn = false + const readFrom = () => ({ text: '', nextOffset: 0, lossy: false }) + const runtime = { spawn(spec) { + if (rejectSpawn) throw new Error('isolated provider failure') + const closed = deferred() + const stop = () => closed.resolve({ exitCode: null, signal: 'SIGTERM' }) + spec.signal?.addEventListener('abort', stop, { once: true }) + if (spec.signal?.aborted) stop() + return { done: closed.promise, collected: { stdout: { readFrom }, stderr: { readFrom } }, terminate: stop } + } } + const f = await fixture(t, { existingBash: true, subprocessRuntime: runtime }) + const { executor, executorScope } = await mountPrivateExecutor(f.root, GitBashSandboxExecutor, createScope) + t.after(() => executorScope.dispose()) + const spec = executor.resolve({ command: 'memory-only command', workdir: process.cwd(), timeoutMs: 20 }) + const foreground = await executor.execute(spec) + assert.equal(foreground.result(), foreground.result(), 'result projection remains memoized') + const timedOut = await bounded(foreground.result(), 'foreground timeout') + assert.equal(timedOut.timedOut, true) + assert.equal(timedOut.aborted, false) + const background = await executor.execute({ ...spec, onExpiry: 'none' }) + await new Promise(resolve => setTimeout(resolve, 40)) + assert.equal(background.status, 'running') + background.kill() + assert.equal((await background.result()).timedOut, false) + const controller = new AbortController() + const cancelled = await executor.execute({ ...spec, onExpiry: 'none', signal: controller.signal }) + controller.abort(new Error('caller cancellation')) + assert.equal((await cancelled.result()).aborted, true) + rejectSpawn = true + const failed = await executor.execute(spec) + await failed.done + await assert.rejects(failed.result(), /isolated provider failure/) + assert.equal(f.root.get('shell'), f.official) +}) diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs new file mode 100644 index 0000000..f7ac505 --- /dev/null +++ b/tests/runtime-regressions.mjs @@ -0,0 +1,178 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { Context, Service } from '@deepseek-ai/cordis' +import { mountInlineTools } from '../src/inline-tools.js' +import { createBashToolModule } from '../src/bash-tool.js' + +function inlineFixture({ visible = false, names = ['bash', 'pwsh'], externalDeny = [] } = {}) { + const listeners = new Map() + const effects = [] + const masks = new Set([new Set(externalDeny)]) + const agent = { ctx: { tools: { restrict({ deny }) { + const mask = new Set(deny) + masks.add(mask) + return () => masks.delete(mask) + } } } } + const registry = { schemas: () => names.filter(name => [...masks].every(mask => !mask.has(name))).map(name => ({ name })) } + const ctx = { + on(name, callback) { listeners.set(name, callback) }, + effect(callback) { effects.push(callback()) }, + } + let enabled = visible + const logs = [] + mountInlineTools(ctx, { + tools: () => registry, agents: () => ({ list: () => [agent] }), + pwshEnabled: () => enabled, log: message => logs.push(message), + createScope() { throw new Error('an existing bash must not mount an executor') }, + }) + return { + agent, logs, + names: () => registry.schemas(agent).map(schema => schema.name), + set: value => { enabled = value; listeners.get('loader/volatile-update')([['pwshTool']]) }, + gate: () => listeners.get('tools/pre-execute')({ agent, name: 'pwsh' }, () => ({ kind: 'allow' })), + dispose: async () => { for (const effect of effects.reverse()) await effect() }, + } +} + +test('runtime: a live agent follows both pwsh switch directions, including repeated flips', async () => { + const f = inlineFixture() + try { + assert.deepEqual(f.names(), ['bash']) + assert.equal(f.gate().kind, 'deny') + f.set(true) + assert.deepEqual(f.names(), ['bash', 'pwsh']) + assert.equal(f.gate().kind, 'allow') + f.set(false) + assert.deepEqual(f.names(), ['bash']) + assert.equal(f.gate().kind, 'deny') + f.set(false) + f.set(true) + assert.deepEqual(f.names(), ['bash', 'pwsh']) + assert.deepEqual(f.logs, []) + } finally { await f.dispose() } + assert.deepEqual(f.names(), ['bash', 'pwsh']) +}) + +test('runtime: pwsh switch/disposal never undo another owner\'s restriction', async () => { + const f = inlineFixture({ externalDeny: ['pwsh'] }) + try { + f.set(true) + assert.deepEqual(f.names(), ['bash']) + f.set(false) + assert.deepEqual(f.names(), ['bash']) + } finally { await f.dispose() } + assert.deepEqual(f.names(), ['bash']) +}) + +async function toolFixture(t, { jobs, executor, sandboxModule } = {}) { + const root = new Context() + const cleanups = [] + const definitions = new Map() + class FakeTools extends Service { + constructor(ctx) { super(ctx, 'tools') } + register(definition) { + return this.ctx.effect(() => { + assert.ok(!definitions.has(definition.name), 'one effective bash registration') + definitions.set(definition.name, definition) + return () => definitions.delete(definition.name) + }) + } + } + for (const [name, value] of Object.entries({ + systemPrompt: { section() {}, getSectionOrder: () => 100 }, + shellEnv: { collect: () => ({ DSH_TEST: 'runtime-regression' }) }, + workingDirectory: { ensure: async () => 'D:/workspace' }, + sandboxPolicy: { resolve: request => ({ mode: request.session?.header.mode ?? 'danger-full-access', workspaceRoot: request.session?.header.cwd ?? 'D:/workspace' }) }, + })) cleanups.push(root.provide(name, value)) + const registryFiber = root.plugin(FakeTools) + cleanups.push(() => registryFiber.dispose()) + await registryFiber + let removeJobs + if (jobs !== undefined) { removeJobs = root.provide('jobs', jobs); cleanups.push(removeJobs) } + const requests = [] + const fallbackExecutor = { + sandboxMode: undefined, + resolve: request => { requests.push(request); return { ...request, workdir: request.workdir ?? 'D:/workspace', timeoutMs: 10 } }, + execute: async spec => ({ result: async () => ({ exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: spec.timeoutMs, + stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false }, + ...(spec.sandboxPolicy ? { sandbox: { mode: spec.sandboxPolicy.mode, denied: false } } : {}), + }) }), + } + const module = createBashToolModule({ executor: executor ?? fallbackExecutor, sandboxModule, log() {} }) + let toolCtx + const apply = module.apply + module.apply = (ctx, config) => { toolCtx = ctx; return apply(ctx, config) } + const fiber = root.plugin(module) + cleanups.push(() => fiber.dispose()) + t.after(async () => { for (const cleanup of cleanups.reverse()) await cleanup() }) + await fiber + return { root, fiber, definitions, requests, removeJobs, ctx: toolCtx } +} + +function assertJsonSchema(node) { + assert.equal(typeof node, 'object') + if (node.required !== undefined) { + assert.ok(Array.isArray(node.required), 'JSON-schema required is an array, never a spec boolean') + assert.ok(node.required.every(key => Object.hasOwn(node.properties, key))) + } + for (const value of Object.values(node.properties ?? {})) assertJsonSchema(value) + for (const key of ['oneOf', 'anyOf', 'allOf']) for (const value of node[key] ?? []) assertJsonSchema(value) + if (node.items !== undefined) assertJsonSchema(node.items) +} + +test('runtime: plain fallback recursively compiles required flags without weakening nested outputs', async t => { + const f = await toolFixture(t) + const definition = f.definitions.get('bash') + assertJsonSchema(definition.parameters) + assertJsonSchema(definition.output.schema) + assert.deepEqual(definition.parameters.required, ['description', 'command']) + const foreground = definition.output.schema.oneOf[2] + assert.ok(foreground.required.includes('stdout')) + assert.ok(foreground.required.includes('stderr')) + assert.deepEqual(foreground.properties.stdout.required, ['text', 'truncated']) + assert.deepEqual(foreground.properties.stderr.required, ['text', 'truncated']) + assert.deepEqual(foreground.properties.sandbox.required, ['mode', 'denied']) + assert.deepEqual(foreground.properties.exitCode.oneOf, [{ type: 'integer' }, { type: 'null' }]) + const value = await definition.execute({ command: 'echo ok', description: 'Check fallback result' }, { signal: new AbortController().signal }) + assert.equal(value.kind, 'foreground') + assert.equal(value.stdout.text, 'ok') + assert.deepEqual(definition.output.render({}, value), [{ type: 'text', text: 'ok' }]) + await assert.rejects(() => definition.execute({ command: '', description: 'Check invalid command' }, {}), /invalid command/) +}) + +test('runtime: actual Cordis jobs unload restores foreground bash and reload does not duplicate it', async t => { + const f = await toolFixture(t, { jobs: {} }) + assert.ok(f.definitions.get('bash').parameters.properties.run_in_background) + assert.equal(f.ctx.fiber.state, 2) + await f.removeJobs() + assert.ok(f.definitions.has('bash'), 'foreground-only bash survives jobs unload') + assert.equal(f.definitions.get('bash').parameters.properties.run_in_background, undefined) + const removeAgain = f.root.provide('jobs', {}) + try { + // Await the injecting child fiber's scheduled activation without an unbounded poll. + await new Promise(resolve => setImmediate(resolve)) + assert.ok(f.definitions.get('bash').parameters.properties.run_in_background) + } finally { await removeAgain() } + assert.equal(f.definitions.size, 1) + assert.equal(f.definitions.get('bash').parameters.properties.run_in_background, undefined) +}) + +test('runtime: missing approval helper still passes the agent\'s standing mode and workspace root', async t => { + const requests = [] + const executor = { + sandboxMode: 'danger-full-access', + resolve(request) { requests.push(request); return { ...request, timeoutMs: 10 } }, + async execute(spec) { return { result: async () => ({ exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 10, + stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false }, sandbox: { mode: spec.sandboxPolicy.mode, denied: false }, + }) } }, + } + const f = await toolFixture(t, { executor }) + const definition = f.definitions.get('bash') + assert.equal(definition.parameters.properties.sandbox_permissions, undefined) + const agent = { session: { header: { mode: 'read-only', cwd: 'D:/child-workspace' } } } + const exec = { agent, signal: new AbortController().signal } + const value = await definition.execute({ command: 'echo ok', description: 'Keep child standing policy' }, exec) + assert.deepEqual(requests[0].sandboxPolicy, { mode: 'read-only', workspaceRoot: 'D:/child-workspace' }) + assert.equal(value.sandbox.mode, 'read-only') + await assert.rejects(() => definition.execute({ command: 'echo ok', description: 'Reject unavailable escalation', sandbox_permissions: 'danger-full-access', justification: 'Need broader mode.' }, exec), /no sandbox helper/) +}) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 6389d58..dd2819c 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -390,8 +390,13 @@ test('sidebar adoption is switchable, default on, and never clobbers manual pick const text = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8') assert.match(text, /adoptSidebar: live\(Schema\.boolean\(\)\.default\(true\)\)/) assert.match(text, /adoptSidebar: \(\) => dialect\(\)\.adoptSidebar !== false/, 'host reads the switch through the live Config reader') - // OFF restores only while the current value is still ours - assert.match(text, /if \(!desired && adopted && current === bashPath\)/) + // OFF enters the owned restore path; that path writes ONLY while the + // current value is still ours. A startup same-value is not our takeover. + const start = text.indexOf('function adoptSidebarShell(') + const body = text.slice(start, text.indexOf('\n// ── plugin', start)) + assert.match(body, /if \(!desired\) \{ await restore\(\); return true \}/) + assert.match(body, /const restore = async \(\) => \{\s*if \(!adopted\) return[\s\S]*const now = readShell\(\)[\s\S]*if \(now === bashPath\) \{\s*await ctx\.get\('settings'\)\.update\(SIDEBAR_NS, \{ terminalShell: previous \}\)/) + assert.match(body, /if \(adopted \|\| current === bashPath\) return true\s*previous = current[\s\S]*await ctx\.get\('settings'\)\.update\(SIDEBAR_NS, \{ terminalShell: bashPath \}\)\s*adopted = true/) }) test('host gates the path dialect behind the posixPaths setting', async () => { @@ -1089,8 +1094,8 @@ test('compositions: full variants mirror the official 0.1.7 row split', async () // placeholder rows (codex / claude-code / ralph) in 0.2.1-alpha.2 // (upstream 8ed0b530ed — codex/claude-code moved to on-demand bundles) assert.equal(row(delegation, 'tool-ralph'), undefined) - assert.equal(row(rows, 'tool-bash').disabled, undefined) - assert.equal(row(rows, 'tool-pwsh').disabled, true) + assert.equal(row(rows, 'tool-bash').disabled, process.platform === 'win32' ? true : undefined) + assert.equal(row(rows, 'tool-pwsh').disabled, process.platform === 'win32' ? undefined : true) assert.equal(row(rows, 'tool-plugin-manager').disabled, kind === 'standard' ? true : undefined) } const ptc = pluginsFor({ kind: 'ptc', gitBash: true, skillsDir: undefined }) @@ -1554,9 +1559,12 @@ test('python switch: the host rebuilds variants on the EFFECTIVE backend and ins // and act on the EFFECTIVE backend rather than the bare intent (v0.26.1) assert.match(src, /const nextPython = pythonBackendActive\(coverage\)/) assert.match(src, /const intentPython = peerFact\(coverage, PEER_PYTHON_FIELD\)/) - // the ROWS change, so live variants are re-registered rather than skipped - assert.match(src, /registeredPython !== peerPython/) - assert.match(src, /pythonActive: peerPython, hostExtras, readBashResolution \}\)/) + // The ROWS change, so compare/build/commit the SAME immutable round fact. + // Reading peerPython again after an await could leave a mixed generation. + assert.match(src, /const pythonActive = peerPython/) + assert.match(src, /registeredPython !== pythonActive/) + assert.match(src, /await registerVariant\(ctx, presetId, \{ gitBashActive, skillsDir, pythonActive, hostExtras, readBashResolution \}\)/) + assert.match(src, /registeredPython = pythonActive/) const compositions = readFileSync(new URL('../src/compositions.js', import.meta.url), 'utf8') assert.match(compositions, /export function pluginsFor\(\{ kind, gitBash, skillsDir, pythonActive = false, hostExtras = \{\} \}\)/, 'the composition entry point takes the effective fact') assert.match(compositions, /const workflowOn = kind !== 'ptc' && pythonActive !== true/) @@ -2034,9 +2042,12 @@ test('no-fallback guards: the plugin never swaps in another shell (patch + execu assert.match(shell, /requireBashPath\(\)/, 'every argv goes through the resolved-path guard') // the guard THROWS on an unresolved bash instead of returning something runnable assert.match(shell, /if \(!resolution\.ok\) \{\s*throw new Error\('dsh-gitbash-shell: no Git for Windows bash found/) - // the composition still pins pwsh rows off in every Git Bash variant + // Full variants preserve official platform gates. On Windows that pwsh-only + // face receives the private Git Bash tool; keeping PowerShell is NOT a + // fallback for an unresolved bash, which still throws through the guard above. const compositions = readFileSync(new URL('../src/compositions.js', import.meta.url), 'utf8') - assert.match(compositions, /const pwshDisabled = gitBash \? true : !win/) + assert.match(compositions, /const bashDisabled = win/) + assert.match(compositions, /const pwshDisabled = !win/) }) test('missing-bash popup: registered globally, win32-only, once per boot, with both actions', () => { @@ -3342,7 +3353,7 @@ test('v0.35.0 default migration: a variant deployment default follows the switch // Official settings seam, the same namespace the preset picker writes. assert.match(hostSource, /settings\.update\('agent-preset-registry', \{ selectedDefault: target \}\)/) // Switch contract: keep-old-modes ON means nothing runs. - assert.match(hostSource, /if \(liveSettings\.variantPresets\(\) === true\) return/) + assert.match(hostSource, /if \(readVariants\(\) === true\) return true/) // Roster check before any write: a missing successor stands down. assert.match(hostSource, /roster\.some\(\(row\) => row && row\.id === target\)/) // Idempotent by observation: only a default still naming OUR variant migrates.