From 28dc90224902944c475e23167c32982fe3da24bf Mon Sep 17 00:00:00 2001 From: howdeploy Date: Tue, 29 Sep 2026 19:56:43 +0300 Subject: [PATCH 1/4] fix(windows): repair orchestration transport and release test portability --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 2 + CHANGELOG.ru.md | 2 + CHANGELOG.zh-CN.md | 2 + src/main/index.ts | 5 ++ .../agent-browser/OrchestrationGateway.ts | 60 ++++++++++++++----- src/main/services/safety/commandFacts.ts | 2 +- tests/base-protection.test.mjs | 4 +- tests/claude-http-hooks.test.mjs | 8 +-- tests/decision-hooks.test.mjs | 2 +- tests/launch-contributors.test.mjs | 3 +- tests/orchestration-gateway.test.mjs | 6 +- tests/orchestration-helper-identity.test.mjs | 1 + tests/orchestration-launch-role.test.mjs | 1 + tests/permission-gate-fail-closed.test.mjs | 2 +- tests/plugin-launch-choices.test.mjs | 3 +- tests/plugin-policy-budget-secrets.test.mjs | 4 +- tests/plugin-startup-order.test.mjs | 3 +- tests/plugin-tools-events-cards.test.mjs | 4 +- tests/session-environments.test.mjs | 53 ++++++++-------- tests/terminal-quit-exits.test.mjs | 8 ++- tests/webgl-context-pool.test.mjs | 2 +- 22 files changed, 117 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28eb8968..b66614a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,8 @@ jobs: run: npm ci - name: Build and self-test current-user-only named-pipe host run: npm run test:windows-pipe-host + - name: Run tests on Windows + run: npm test - name: Type-check Windows gateway integration run: npm run typecheck - name: Verify the packaged host resource diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fcc6ad..d9c498a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## 1.7.0 +- Fixed Windows orchestration startup by using the existing current-user-only pipe host, and recognized expanded Windows paths in private-data protection. The full Windows test suite now runs before PR merge as well as before release packaging. + - Added pixel terminal skins and agent-generated theme packs from PR #98, with independent Canvas backgrounds and terminal borders. Theme creation now includes inline instructions, labeled upload slots, an example, and explicit preview guidance. Canvas patterns appear before background selection and explain when an image overrides them. - Restored readable terminal summary tiles when zoomed out, enabled Master artwork automatically for orchestrators, and restored edge/corner resizing for pixel skins without resetting manually chosen or restored sizes. - Integrated PR #100: startup navigation race fixes, safe provider API-key pasting, recovery from uncaught renderer errors, and protection for CanvasTTY's private control data. PR #100 consolidates the earlier fixes from #96, #97, and #99. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index b4de8fb1..c8c37e31 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,8 @@ ## 1.7.0 +- Исправлен запуск оркестрации на Windows через существующий pipe-host с доступом только текущему пользователю и распознавание раскрытых Windows-путей при защите приватных данных. Полный набор Windows-тестов теперь выполняется до слияния PR, а не только при сборке релиза. + - Добавлены пиксельные скины терминалов и создаваемые агентом пакеты тем из PR #98 с независимым выбором фона Canvas и рамок терминалов. В создание темы добавлены краткая инструкция, подписанные ячейки загрузки, пример и пояснения предсмотра. Рисунок Canvas расположен перед выбором фона с пояснением, когда изображение его заменяет. - Возвращены читаемые плитки терминалов при отдалении, автоматический Master-скин для оркестраторов и ресайз пиксельных окон за края и углы без сброса выбранных или восстановленных размеров. - Включён PR #100: исправлены гонка навигации при запуске, вставка API-ключей и восстановление после необработанных ошибок интерфейса; защищены приватные управляющие данные CanvasTTY. PR #100 объединяет предыдущие исправления из #96, #97 и #99. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 89ae5eb3..b92b7220 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,8 @@ ## 1.7.0 +- Windows 编排启动改用现有的仅限当前用户访问的管道服务,并修复私有数据保护对展开后的 Windows 路径的识别。完整 Windows 测试现在会在 PR 合并前运行,而不再仅在发布打包时运行。 + - 集成 PR #98 的像素终端皮肤及智能体生成的主题包,画布背景与终端边框可以独立选择。主题创建界面新增简短说明、上传槽位标签、示例和预览提示;画布图案位于背景选择之前,并说明背景图片何时会覆盖图案。 - 恢复缩小时可读的终端摘要卡片,为编排者自动使用 Master 皮肤,并恢复像素窗口的边缘和角落缩放,不再重置手动调整或恢复的尺寸。 - 集成 PR #100,修复启动导航竞态、API 密钥粘贴及未捕获界面错误后的恢复,并保护 CanvasTTY 的私有控制数据。该 PR 汇总了 #96、#97 和 #99 的修复。 diff --git a/src/main/index.ts b/src/main/index.ts index e2ffe0fb..6ebeb42f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -580,6 +580,11 @@ async function initializeServices(): Promise { // other sessions never receive capabilities. orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), + windowsHostPath: process.platform === "win32" + ? app.isPackaged + ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) + : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) + : undefined, handler: new ScopedOrchestrationHandler(new AgentControlService(terminalManager), pluginTools) }); await orchestrationGateway.start(); diff --git a/src/main/services/agent-browser/OrchestrationGateway.ts b/src/main/services/agent-browser/OrchestrationGateway.ts index 3fcd8b21..988615b7 100644 --- a/src/main/services/agent-browser/OrchestrationGateway.ts +++ b/src/main/services/agent-browser/OrchestrationGateway.ts @@ -1,6 +1,6 @@ import { randomBytes, randomUUID } from "node:crypto"; import { createServer } from "node:net"; -import type { Server, Socket } from "node:net"; +import type { Server } from "node:net"; import { join } from "node:path"; import type { OrchestrationBridgeErrorPayload, @@ -32,6 +32,8 @@ import { tokenMatches } from "../gatewaySocket.ts"; +import { WindowsPipeHostTransport, type AgentGatewaySocket } from "./WindowsPipeHostTransport.ts"; + const CAPABILITY_TTL_MS = 60_000; interface CapabilityLease { @@ -47,7 +49,7 @@ interface CapabilityLease { } interface Connection { - socket: Socket; + socket: AgentGatewaySocket; decoder: OrchestrationNdjsonDecoder; lease: CapabilityLease | null; authenticated: boolean; @@ -59,6 +61,7 @@ interface Connection { export interface OrchestrationGatewayOptions { runtimeDirectory: string; + windowsHostPath?: string; handler: OrchestrationCommandHandler; capabilityTtlMs?: number; heartbeatIntervalMs?: number; @@ -72,6 +75,8 @@ export class OrchestrationGateway { private readonly connections = new Set(); private readonly handler: OrchestrationCommandHandler; private readonly runtimeDirectory: string; + private readonly windowsHostPath: string | undefined; + private windowsTransport: WindowsPipeHostTransport | null = null; private readonly capabilityTtlMs: number; private readonly heartbeatIntervalMs: number; private readonly heartbeatExpiryMs: number; @@ -85,6 +90,7 @@ export class OrchestrationGateway { constructor(options: OrchestrationGatewayOptions) { this.handler = options.handler; this.runtimeDirectory = options.runtimeDirectory; + this.windowsHostPath = options.windowsHostPath; this.capabilityTtlMs = options.capabilityTtlMs ?? CAPABILITY_TTL_MS; this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? ORCHESTRATION_HEARTBEAT_INTERVAL_MS; this.heartbeatExpiryMs = options.heartbeatExpiryMs ?? ORCHESTRATION_HEARTBEAT_EXPIRY_MS; @@ -109,19 +115,38 @@ export class OrchestrationGateway { async start(): Promise { if (this.running) return; - // Unix domain sockets cap at ~104 path bytes (macOS); fall back to a short - // current-user directory exactly like the browser gateway does. - let runtimeDirectory = this.runtimeDirectory; - this.ownedRuntimeDirectory = null; - let endpoint = join(runtimeDirectory, `orchestration-${randomUUID()}.sock`); - if (Buffer.byteLength(endpoint, "utf8") > MAX_UNIX_SOCKET_PATH_BYTES) { - runtimeDirectory = join("/tmp", `ctty-orch-${process.getuid?.() ?? "user"}-${randomUUID().slice(0, 8)}`); - this.ownedRuntimeDirectory = runtimeDirectory; - endpoint = join(runtimeDirectory, "orchestration.sock"); + if (process.platform === "win32") { + if (!this.windowsHostPath) throw new Error("Orchestration requires the current-user Windows pipe host."); + const transport = new WindowsPipeHostTransport({ hostPath: this.windowsHostPath }); + this.windowsTransport = transport; + transport.on("fatal", () => { + void this.stop().catch((error) => console.warn("Orchestration pipe host shutdown failed.", error)); + }); + try { + const endpoint = await transport.start((socket) => this.accept(socket)); + if (this.windowsTransport !== transport) throw new Error("Orchestration is shutting down."); + this.socketEndpoint = endpoint; + } catch (error) { + await transport.close(); + this.windowsTransport = null; + this.socketEndpoint = null; + throw error; + } + } else { + // Unix domain sockets cap at ~104 path bytes (macOS); fall back to a short + // current-user directory exactly like the browser gateway does. + let runtimeDirectory = this.runtimeDirectory; + this.ownedRuntimeDirectory = null; + let endpoint = join(runtimeDirectory, `orchestration-${randomUUID()}.sock`); + if (Buffer.byteLength(endpoint, "utf8") > MAX_UNIX_SOCKET_PATH_BYTES) { + runtimeDirectory = join("/tmp", `ctty-orch-${process.getuid?.() ?? "user"}-${randomUUID().slice(0, 8)}`); + this.ownedRuntimeDirectory = runtimeDirectory; + endpoint = join(runtimeDirectory, "orchestration.sock"); + } + await makePrivateDirectory(runtimeDirectory, { recursive: true }); + this.socketEndpoint = endpoint; + await listenOnEndpoint(this.server, endpoint); } - await makePrivateDirectory(runtimeDirectory, { recursive: true }); - this.socketEndpoint = endpoint; - await listenOnEndpoint(this.server, endpoint); this.running = true; this.heartbeatTimer = setInterval(() => this.sweepConnections(), this.heartbeatIntervalMs); this.heartbeatTimer.unref?.(); @@ -134,8 +159,11 @@ export class OrchestrationGateway { } for (const connection of [...this.connections]) this.closeConnection(connection, "closed"); for (const lease of [...this.leases.values()]) this.expireLease(lease); + const transport = this.windowsTransport; + this.windowsTransport = null; + if (transport) await transport.close(); await closeServer(this.server); - if (this.socketEndpoint !== null) { + if (this.socketEndpoint !== null && process.platform !== "win32") { await removeEndpoint(this.socketEndpoint, this.ownedRuntimeDirectory, { socketFile: true, ignoreErrors: true }); } this.socketEndpoint = null; @@ -190,7 +218,7 @@ export class OrchestrationGateway { } } - private accept(socket: Socket): void { + private accept(socket: AgentGatewaySocket): void { if (this.connections.size >= MAX_CONNECTED_ORCHESTRATORS) { socket.destroy(); return; diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index aefd9477..70713538 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -558,7 +558,7 @@ function codePath(text: string, ctx: PathContext): string | null { let value = text.trim().replace(/^file:\/\//iu, '/'); value = value.replace(/^(?:\$HOME|\$\{HOME\})(?=[\\/]|$)/u, ctx.home).replace(/^(?:\$TMPDIR|\$\{TMPDIR\})(?=[\\/]|$)/u, ctx.temp); if (value === '~' || value.startsWith('~/')) value = ctx.home + value.slice(1); - return value.startsWith('/') && value.length > 1 ? value : null; + return isAbsolute(value) && value.length > 1 ? value : null; } /** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ diff --git a/tests/base-protection.test.mjs b/tests/base-protection.test.mjs index 08454c91..e0265ed6 100644 --- a/tests/base-protection.test.mjs +++ b/tests/base-protection.test.mjs @@ -106,7 +106,7 @@ test("each deny tells the model what to do instead; a write only to the temporar const outsideWrite = check("Write", { file_path: join(home, "Downloads", "hello.txt"), content: "hi" }); assert.match(outsideWrite.message, /outside the project folder/u); assert.match(outsideWrite.message, /ask the person/u); - for (const command of ["echo x > /tmp/scratch.txt", "mkdir -p /tmp/work", "cp src/a.ts $TMPDIR/a.ts"]) { + for (const command of ["echo x > \"$TMPDIR/scratch.txt\"", "mkdir -p \"$TMPDIR/work\"", "cp src/a.ts \"$TMPDIR/a.ts\""]) { const result = check("Bash", { command }); assert.equal(result.rule, "write-outside", command); assert.match(result.message, /temporary folder/u, command); @@ -133,7 +133,7 @@ test("cut hook input: a file tool's path at the start of the preview can still a }); test("the agent's own plan and memory folders are not outside; the rest of its config folder and escapes stay denied", () => { - symlinkSync("/etc", join(home, ".claude", "plans", "link")); + symlinkSync(outside, join(home, ".claude", "plans", "link"), "junction"); for (const action of [edit(join(home, ".claude/plans/plan-1.md")), edit(join(home, ".claude/projects/p1/memory/MEMORY.md")), shell("echo x > ~/.claude/plans/a.md")]) { assert.equal(rule(action), null, JSON.stringify(action)); } diff --git a/tests/claude-http-hooks.test.mjs b/tests/claude-http-hooks.test.mjs index 079f17dd..2b51c0b2 100644 --- a/tests/claude-http-hooks.test.mjs +++ b/tests/claude-http-hooks.test.mjs @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import test from "node:test"; import { AGENT_RUNTIME_ENV, CAPTURE_RESULT_ENV, CLAUDE_HTTP_HOOK } from "../src/agent-runtime/runtime-protocol.mjs"; @@ -300,7 +300,7 @@ test("the policy keeps the helper wherever an HTTP hook could not reach the gate const files = new Map(); const policy = (options = {}) => new ClaudeHttpHookPolicy({ platform: "darwin", home: "/profile", managedSettingsPaths: ["/managed/managed-settings.json"], - readText: (path) => files.get(path) ?? null, version: () => "2.1.281", ...options + readText: (path) => files.get(resolve(path)) ?? null, version: () => "2.1.281", ...options }); const facts = { executable: "/bin/claude", profile: "default", environmentWrapped: false, env: { PATH: "/bin" }, args: [], cwd: "/work/repo/sub" }; assert.deepEqual(policy().verdict(facts), { ok: true }); @@ -322,7 +322,7 @@ test("the policy keeps the helper wherever an HTTP hook could not reach the gate const withFile = (path, value) => { files.clear(); - files.set(path, JSON.stringify(value)); + files.set(resolve(path), JSON.stringify(value)); }; withFile("/managed/managed-settings.json", { httpHookAllowedEnvVars: ["X"] }); assert.match(refused({}), /headers/u); @@ -333,7 +333,7 @@ test("the policy keeps the helper wherever an HTTP hook could not reach the gate withFile("/work/repo/.claude/settings.local.json", { allowedHttpHookUrls: ["https://x/*"] }); assert.match(refused({}), /URLs/u); // The project walk stops at the repository root. - files.set("/work/repo/sub/.git", "gitdir: /elsewhere"); + files.set(resolve("/work/repo/sub/.git"), "gitdir: /elsewhere"); assert.equal(policy().verdict(facts).ok, true); files.clear(); withFile("/work/repo/.claude/settings.json", { sandbox: { enabled: false }, env: { FOO: "1" } }); diff --git a/tests/decision-hooks.test.mjs b/tests/decision-hooks.test.mjs index 46bfe4e5..5b16f8e8 100644 --- a/tests/decision-hooks.test.mjs +++ b/tests/decision-hooks.test.mjs @@ -266,7 +266,7 @@ test("launch: the decision hook is added only when wanted, per provider, with or const claude = JSON.parse(adapters.prepare("claude", "t1", false, true).args[1]); assert.deepEqual(Object.keys(claude.hooks), ["PreToolUse"]); assert.equal(claude.hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit"); - assert.match(claude.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs' 'pretool'$/u); + assert.match(claude.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs['"] ['"]pretool['"]$/u); assert.ok(JSON.parse(adapters.prepare("claude", "t1", true, true).args[1]).hooks.Stop, "status hooks stay alongside"); const codex = adapters.prepare("codex", "t2", false, true).args.join(" "); assert.match(codex, /hooks\.PreToolUse=\[\{matcher="Bash\|apply_patch\|Edit\|Write"/u); diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index 24b8d56a..ee1cab0a 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -368,7 +369,7 @@ test("restore without the plugin holds the card stopped with its reason and keep }); test("end to end: the example service prepares a launch over JSON-RPC", async (t) => { - const root = new URL(".", example).pathname; + const root = fileURLToPath(new URL(".", example)); const entryPath = join(root, "services", "launcher.mjs"); const dataDir = await mkdtemp(join(tmpdir(), "canvastty-launch-e2e-")); t.after(() => rm(dataDir, { recursive: true, force: true })); diff --git a/tests/orchestration-gateway.test.mjs b/tests/orchestration-gateway.test.mjs index 7ec8cfe1..59388143 100644 --- a/tests/orchestration-gateway.test.mjs +++ b/tests/orchestration-gateway.test.mjs @@ -69,7 +69,8 @@ async function fixture(t) { const control = new AgentControlService(terminals); const gateway = new OrchestrationGateway({ runtimeDirectory: join(directory, "runtime"), - handler: new ScopedOrchestrationHandler(control) + handler: new ScopedOrchestrationHandler(control), + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe") }); await gateway.start(); t.after(() => gateway.stop()); @@ -340,7 +341,8 @@ test("cancel reaches the running command and the answer is CANCELED, not the lat signal = abortSignal ?? null; return new Promise((resolve) => { finish = resolve; }); } - } + }, + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe") }); await gateway.start(); t.after(() => gateway.stop()); diff --git a/tests/orchestration-helper-identity.test.mjs b/tests/orchestration-helper-identity.test.mjs index eb6f0aed..755ac719 100644 --- a/tests/orchestration-helper-identity.test.mjs +++ b/tests/orchestration-helper-identity.test.mjs @@ -48,6 +48,7 @@ test("the orchestration helper authenticates with the card's own capability and const calls = []; const gateway = new OrchestrationGateway({ runtimeDirectory: join(runtimeDirectory, "runtime"), + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe"), handler: { async execute(sessionId, request) { calls.push({ sessionId, tool: request.tool }); diff --git a/tests/orchestration-launch-role.test.mjs b/tests/orchestration-launch-role.test.mjs index e9b41fe2..50a15a7b 100644 --- a/tests/orchestration-launch-role.test.mjs +++ b/tests/orchestration-launch-role.test.mjs @@ -24,6 +24,7 @@ async function fixture(t) { const terminals = new TerminalManager(() => undefined, availableRegistry(), agentBrowser, undefined, true, fakeSpawner(calls)); const gateway = new OrchestrationGateway({ runtimeDirectory: join(directory, "runtime"), + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe"), handler: new ScopedOrchestrationHandler(new AgentControlService(terminals)) }); await gateway.start(); diff --git a/tests/permission-gate-fail-closed.test.mjs b/tests/permission-gate-fail-closed.test.mjs index 6bf05d96..a786939d 100644 --- a/tests/permission-gate-fail-closed.test.mjs +++ b/tests/permission-gate-fail-closed.test.mjs @@ -238,7 +238,7 @@ test("launch: the decision hook carries the fail-closed flag; a launch without i t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); const helper = { command: "/opt/CanvasTTY", args: ["/opt/CanvasTTY/hook-helper.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }; const permissionGate = { command: "/opt/CanvasTTY", args: ["/opt/CanvasTTY/permission-gate.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }; - const options = { helper, runtimeDirectory, openCodePluginPath: "/opt/CanvasTTY/opencode-plugin.mjs", permissionGate, + const options = { platform: "darwin", helper, runtimeDirectory, openCodePluginPath: "/opt/CanvasTTY/opencode-plugin.mjs", permissionGate, kimiHomeDirectory: join(runtimeDirectory, "kimi"), hermesHomeDirectory: join(runtimeDirectory, "hermes"), grokHomeDirectory: join(runtimeDirectory, "grok") }; const adapters = new ProviderRuntimeLaunchAdapters(options); const flag = new RegExp(`${DECISION_FAIL_CLOSED_ENV}='1' .*permission-gate\\.mjs' 'pretool'$`, "u"); diff --git a/tests/plugin-launch-choices.test.mjs b/tests/plugin-launch-choices.test.mjs index 811c4e19..7b64ed3f 100644 --- a/tests/plugin-launch-choices.test.mjs +++ b/tests/plugin-launch-choices.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -110,7 +111,7 @@ test("a service select saves any short text; the service checks it when it prepa }); test("the example service offers its profiles and refuses one that is gone", async (t) => { - const root = example.pathname; + const root = fileURLToPath(example); const entryPath = join(root, "services", "launcher.mjs"); const dataDir = await mkdtemp(join(tmpdir(), "canvastty-choices-data-")); t.after(() => rm(dataDir, { recursive: true, force: true })); diff --git a/tests/plugin-policy-budget-secrets.test.mjs b/tests/plugin-policy-budget-secrets.test.mjs index e7e2c5bb..35ab4ba9 100644 --- a/tests/plugin-policy-budget-secrets.test.mjs +++ b/tests/plugin-policy-budget-secrets.test.mjs @@ -115,7 +115,7 @@ test("launch: the hook, the helper's environment and the gateway follow the sess assert.doesNotMatch(plain.command, new RegExp(DECISION_BUDGET_ENV, "u"), "the default budget changes nothing"); const long = JSON.parse(adapters.prepare("claude", "t1", false, true, 45_000).args[1]).hooks.PreToolUse[0].hooks[0]; assert.equal(long.timeout, 52); - assert.match(long.command, new RegExp(`${DECISION_BUDGET_ENV}='45000'`, "u")); + assert.match(long.command, new RegExp(process.platform === "win32" ? `set "${DECISION_BUDGET_ENV}=45000"` : `${DECISION_BUDGET_ENV}='45000'`, "u")); const qwen = adapters.prepare("qwen", "t2", false, true, 45_000); assert.equal(JSON.parse(await readFile(qwen.environment.QWEN_CODE_SYSTEM_SETTINGS_PATH, "utf8")).hooks.PreToolUse[0].hooks[0].timeout, 52_000); qwen.releaseConfiguration(); @@ -319,7 +319,7 @@ test("a decide budget above the supervisor's 15 s request default is honored thr const { PluginServiceSupervisor, MAX_HOST_CALL_TIMEOUT_MS } = await import("../src/main/services/PluginServiceSupervisor.ts"); const { DecisionHooks } = await import("../src/main/services/DecisionHooks.ts"); const { MAX_DECIDE_TIMEOUT_MS, permissionGateTimings } = await import("../src/agent-runtime/runtime-protocol.mjs"); - const entryPath = new URL("./fixtures/slow-decide-service.mjs", import.meta.url).pathname; + const entryPath = fileURLToPath(new URL("./fixtures/slow-decide-service.mjs", import.meta.url)); const dataDir = await mkdtemp(join(tmpdir(), "canvastty-slow-decide-")); t.after(() => rm(dataDir, { recursive: true, force: true })); // Production construction: no requestTimeoutMs, so surface requests keep the 15 s default. diff --git a/tests/plugin-startup-order.test.mjs b/tests/plugin-startup-order.test.mjs index 98e7c5d7..bccafced 100644 --- a/tests/plugin-startup-order.test.mjs +++ b/tests/plugin-startup-order.test.mjs @@ -5,6 +5,7 @@ * restored/new card events without a disable/enable cycle. No real CLI runs; HOME is the runner's fake one. */ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -16,7 +17,7 @@ import { PluginSessions } from "../src/main/services/PluginSessions.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; -const entryPath = new URL("./fixtures/startup-subscriber-service.mjs", import.meta.url).pathname; +const entryPath = fileURLToPath(new URL("./fixtures/startup-subscriber-service.mjs", import.meta.url)); const cwd = process.cwd(); const at = { x: 0, y: 0 }; const waitFor = async (predicate, timeoutMs = 8_000) => { diff --git a/tests/plugin-tools-events-cards.test.mjs b/tests/plugin-tools-events-cards.test.mjs index 76e24b8c..47d8f260 100644 --- a/tests/plugin-tools-events-cards.test.mjs +++ b/tests/plugin-tools-events-cards.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -243,6 +244,7 @@ test("the bridge lists per-session tools over the socket and the MCP helper retu }); const gateway = new OrchestrationGateway({ runtimeDirectory: join(directory, "rt"), + windowsHostPath: join(process.cwd(), "build", "windows-agent-pipe-host", "canvastty-windows-agent-pipe-host.exe"), handler: new ScopedOrchestrationHandler(new AgentControlService(terminals), plugin) }); await gateway.start(); @@ -468,7 +470,7 @@ test("collect-demo through the real supervisor: the action and the tool return g const { terminals, sessions } = world(); const badges = []; let cards = null; - const pluginRoot = new URL(".", example).pathname; + const pluginRoot = fileURLToPath(new URL(".", example)); const entryPath = join(pluginRoot, "services", "collect.mjs"); const permissions = validatePluginManifest(exampleManifest).permissions; const supervisor = new PluginServiceSupervisor({ diff --git a/tests/session-environments.test.mjs b/tests/session-environments.test.mjs index 702a3ce8..1d52bb85 100644 --- a/tests/session-environments.test.mjs +++ b/tests/session-environments.test.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, isAbsolute, join } from "node:path"; import test from "node:test"; import { EnvironmentRegistry, resolveCommand } from "../src/main/services/EnvironmentRegistry.ts"; import { validatePluginManifest } from "../src/main/services/PluginManager.ts"; @@ -33,7 +34,7 @@ function clis() { return { get(provider) { return { state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", - environment: { PATH: "/usr/bin" }, checked: [] }; + environment: { PATH: process.env.PATH ?? "" }, checked: [] }; }, snapshot() { return {}; } }; @@ -84,7 +85,7 @@ function registryFixture({ providers = () => [provider()], answers = {}, secrets const defaultAnswers = (extra = {}) => ({ prepare: { ref: { box: "b-1" }, label: "box b-1" }, - wrap: (params) => ({ command: "/bin/sh", args: ["-c", "exit 0", "wrapped", params.command, ...params.args], cwd: params.cwd }), + wrap: (params) => ({ command: process.execPath, args: ["-c", "exit 0", "wrapped", params.command, ...params.args], cwd: params.cwd }), resume: { ok: true }, release: {}, describe: { label: "box b-1 (running)", detail: "demo box" }, @@ -172,31 +173,31 @@ test("prepare answers are validated and a timeout refuses", async () => { test("wrap output is validated: program, no shell string, env rules, secrets, cwd", async () => { const environment = { pluginId: PLUGIN, kind: "box", ref: {}, label: "box" }; const request = { sessionId: "s1", provider: "terminal", secretEnvNames: [], takenEnv: new Set(["CTTY_CONTRIBUTED"]), - path: "/usr/bin:/bin", launch: { command: "/bin/sh", args: ["-l"], env: {}, cwd } }; + path: dirname(process.execPath), launch: { command: process.execPath, args: ["-l"], env: {}, cwd } }; const wrap = (answer, extra = {}) => registryFixture({ answers: { wrap: answer }, secrets: { [`${PLUGIN}/token`]: SECRET }, ...extra }).registry.wrap(environment, request); - const bare = await wrap({ command: "sh", args: ["-c", "true"] }); + const bare = await wrap({ command: basename(process.execPath), args: ["-e", "true"] }); assert.equal(bare.ok, true); - assert.equal(bare.command, resolveCommand("sh", "/usr/bin:/bin")); - assert.ok(bare.command.startsWith("/")); + assert.equal(bare.command, resolveCommand(basename(process.execPath), dirname(process.execPath))); + assert.ok(isAbsolute(bare.command)); assert.equal(bare.cwd, cwd); for (const command of ["sh -c 'rm -rf ~'", "bin/sh", "./sh", "/definitely/missing/program", "no-such-program-canvastty"]) { assert.match((await wrap({ command, args: [] })).reason, /absolute path to a program or a bare program name on PATH; CanvasTTY runs no shell string/u, command); } - assert.match((await wrap({ command: "/bin/sh", args: ["a\u0000b"] })).reason, /without NUL/u); - assert.match((await wrap({ command: "/bin/sh", args: "-c true" })).reason, /args must be an array/u); - assert.match((await wrap({ command: "/bin/sh", cwd: "/definitely/missing" })).reason, /cwd must be an existing absolute folder/u); - assert.match((await wrap({ command: "/bin/sh", env: { PATH: "/tmp" } })).reason, /env PATH is reserved/u); - assert.match((await wrap({ command: "/bin/sh", env: { CANVASTTY_AGENT_TOKEN: "x" } })).reason, /reserved/u); - assert.match((await wrap({ command: "/bin/sh", env: { CTTY_CONTRIBUTED: "x" } })).reason, /already sets for this launch/u); - assert.match((await wrap({ command: "/bin/sh", shell: true })).reason, /unknown key shell/u); - assert.match((await wrap({ command: "/bin/sh", secretEnv: { BOX_TOKEN: "token" } })).reason, /without the secrets permission/u); - const secret = await wrap({ command: "/bin/sh", env: { BOX_NAME: "b" }, secretEnv: { BOX_TOKEN: "token" } }, + assert.match((await wrap({ command: process.execPath, args: ["a\u0000b"] })).reason, /without NUL/u); + assert.match((await wrap({ command: process.execPath, args: "-c true" })).reason, /args must be an array/u); + assert.match((await wrap({ command: process.execPath, cwd: "/definitely/missing" })).reason, /cwd must be an existing absolute folder/u); + assert.match((await wrap({ command: process.execPath, env: { PATH: "/tmp" } })).reason, /env PATH is reserved/u); + assert.match((await wrap({ command: process.execPath, env: { CANVASTTY_AGENT_TOKEN: "x" } })).reason, /reserved/u); + assert.match((await wrap({ command: process.execPath, env: { CTTY_CONTRIBUTED: "x" } })).reason, /already sets for this launch/u); + assert.match((await wrap({ command: process.execPath, shell: true })).reason, /unknown key shell/u); + assert.match((await wrap({ command: process.execPath, secretEnv: { BOX_TOKEN: "token" } })).reason, /without the secrets permission/u); + const secret = await wrap({ command: process.execPath, env: { BOX_NAME: "b" }, secretEnv: { BOX_TOKEN: "token" } }, { providers: () => [provider({ secrets: true })] }); assert.deepEqual(secret.ok && [secret.env, secret.secrets], [{ BOX_NAME: "b", BOX_TOKEN: SECRET }, [SECRET]]); - const missing = await wrap({ command: "/bin/sh", secretEnv: { BOX_TOKEN: "unset" } }, { providers: () => [provider({ secrets: true })] }); + const missing = await wrap({ command: process.execPath, secretEnv: { BOX_TOKEN: "unset" } }, { providers: () => [provider({ secrets: true })] }); assert.match(missing.reason, /secret unset is not set/u); assert.match((await wrap({ refuse: { reason: "box is paused" } })).reason, /Env: box is paused/u); }); @@ -213,7 +214,7 @@ test("lifecycle: prepare, wrap, describe, saved ref; the environment never sees const wrapParams = requests[1].params; assert.deepEqual(wrapParams.ref, { box: "b-1" }); assert.equal(Object.keys(wrapParams.env).some((key) => /^(CANVASTTY_|PATH$|TERM$)/u.test(key)), false); - assert.equal(calls[0].command, "/bin/sh"); + assert.equal(calls[0].command, process.execPath); assert.deepEqual(calls[0].args.slice(0, 3), ["-c", "exit 0", "wrapped"]); assert.equal(calls[0].options.cwd, cwd); await waitFor(() => card(manager, created.id).environment?.label === "box b-1 (running)"); @@ -271,17 +272,17 @@ test("restore resumes environments first, then parents before children; stopped }, wrap: (params) => { order.push(`wrap:${params.sessionId}`); - return { command: "/bin/sh", args: [params.sessionId], cwd: params.cwd }; + return { command: process.execPath, args: [params.sessionId], cwd: params.cwd }; } }) }); const { manager, calls } = await managerFixture(t, registry, { directory }); - await waitFor(() => calls.filter((call) => call.command === "/bin/sh").length === 2); + await waitFor(() => calls.filter((call) => call.command === process.execPath).length === 2); // Every resume is answered before any wrapped launch starts, and the parent launches before its child. const firstWrap = order.findIndex((entry) => entry.startsWith("wrap:")); assert.deepEqual(order.slice(0, firstWrap).sort(), ["resume:child", "resume:parent", "resume:stopped"]); assert.deepEqual(order.slice(firstWrap), ["wrap:parent", "wrap:child"]); assert.equal(requests.some((request) => request.params.sessionId === "missing"), false); - assert.equal(calls.filter((call) => call.command !== "/bin/sh").length, 1, "only the local card runs locally"); + assert.equal(calls.filter((call) => call.command !== process.execPath).length, 1, "only the local card runs locally"); const stopped = card(manager, "stopped"); assert.equal(stopped.status, "failed"); @@ -298,7 +299,7 @@ test("restore resumes environments first, then parents before children; stopped // Restarting a stopped card asks the plugin to resume again, never runs it locally. const again = await managerFixture(t, registryFixture({ answers: defaultAnswers({ resume: { stopped: { reason: "still gone" } } }) }).registry, { directory }); - assert.equal(again.calls.filter((call) => call.command !== "/bin/sh").length, 1); + assert.equal(again.calls.filter((call) => call.command !== process.execPath).length, 1); }); test("an exited card resumes its environment only when restarted", async (t) => { @@ -365,7 +366,7 @@ test("wrap secrets are masked in agent-readable text", async (t) => { const { registry } = registryFixture({ providers: () => [provider({ secrets: true })], secrets: { [`${PLUGIN}/token`]: SECRET }, - answers: defaultAnswers({ wrap: (params) => ({ command: "/bin/sh", args: params.args, cwd: params.cwd, secretEnv: { BOX_TOKEN: "token" } }) }) + answers: defaultAnswers({ wrap: (params) => ({ command: process.execPath, args: params.args, cwd: params.cwd, secretEnv: { BOX_TOKEN: "token" } }) }) }); const { manager, calls } = await managerFixture(t, registry); const created = manager.create({ provider: "terminal", profile: "normal", cwd, position: at, environment: choice }); @@ -390,7 +391,7 @@ test("the env-worktree example: a terminal in a real git worktree, restored in i git("add", "."); git("commit", "-q", "-m", "init"); - const pluginRoot = new URL(".", example).pathname; + const pluginRoot = fileURLToPath(new URL(".", example)); const entryPath = join(pluginRoot, "services", "worktree.mjs"); const dataDir = join(root, "plugin-data"); const supervisor = new PluginServiceSupervisor({ @@ -482,7 +483,7 @@ test("quitting while prepare is pending: the choice is saved, the card comes bac third.manager.restart(pending.id); await waitFor(() => third.calls.length === 1); assert.deepEqual(second.requests.filter((request) => request.step === "prepare").map((request) => request.params.options), [{ name: "two" }]); - assert.equal(third.calls[0].command, "/bin/sh", "wrapped by the environment, not the local shell"); + assert.equal(third.calls[0].command, process.execPath, "wrapped by the environment, not the local shell"); await waitFor(async () => (await saved(directory).catch(() => []))[0]?.environment?.ref?.box === "b-1"); assert.equal((await saved(directory))[0].environmentChoice, undefined, "a prepared environment replaces the choice"); @@ -522,7 +523,7 @@ test("a failed prepare keeps its choice across an app restart; manual Restart pr answer = { ref: { box: "b-2" }, label: "box b-2" }; second.manager.restart(created.id); await waitFor(() => second.calls.length === 1); - assert.equal(second.calls[0].command, "/bin/sh"); + assert.equal(second.calls[0].command, process.execPath); assert.deepEqual(requests.filter((request) => request.step === "prepare").map((request) => request.params.options), [{ name: "two" }, { name: "two" }, { name: "two" }]); assert.equal(first.calls.length, 0); diff --git a/tests/terminal-quit-exits.test.mjs b/tests/terminal-quit-exits.test.mjs index 1ba5c501..4f408aee 100644 --- a/tests/terminal-quit-exits.test.mjs +++ b/tests/terminal-quit-exits.test.mjs @@ -51,7 +51,11 @@ test("quitting waits for every hung-up PTY to exit before it resolves", async () }); test("a PTY that ignores the hang-up is killed after the wait, and quitting waits for that exit too", async () => { - const stubborn = fakePty((signal, exit) => { if (signal === "SIGKILL") setTimeout(() => exit(137), 10); }); + let signals = 0; + const stubborn = fakePty((signal, exit) => { + signals += 1; + if (signal === "SIGKILL" || (process.platform === "win32" && signals === 2)) setTimeout(() => exit(137), 10); + }); const polite = fakePty((_signal, exit) => exit(0)); const manager = managerWith([stubborn, polite]); await manager.shutdown(); @@ -103,7 +107,7 @@ test("an exit handler that throws never escapes into node-pty's native exit call }); test("the quit path awaits the PTY exits before the app may finish quitting", () => { - const main = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8"); + const main = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8").replaceAll("\r\n", "\n"); const shutdown = main.slice(main.indexOf("async function shutdownServices")); const body = shutdown.slice(0, shutdown.indexOf("\n}\n")); assert.match(body, /terminalManager\.shutdown\(\)[\s\S]*waitForProcessExits\(\)[\s\S]*await ptyExits;\s*$/u); diff --git a/tests/webgl-context-pool.test.mjs b/tests/webgl-context-pool.test.mjs index fe45e070..73ffc17c 100644 --- a/tests/webgl-context-pool.test.mjs +++ b/tests/webgl-context-pool.test.mjs @@ -348,5 +348,5 @@ test("TerminalCard routes its renderer through the pool and frees the context on assert.match(card, /WEBGL_lose_context/); assert.doesNotMatch(card, /if \(focused && !summaryMode && zoom <= WEBGL_MAX_SCALE\) enableWebgl/); const canvas = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8"); - assert.match(canvas, /webglContextPool\(\)\.viewportChanged\(\);\n \}, \[camera\.x, camera\.y, camera\.zoom/); + assert.match(canvas, /webglContextPool\(\)\.viewportChanged\(\);\r?\n \}, \[camera\.x, camera\.y, camera\.zoom/); }); From 16883830454220a6c697d15c6e0a92288f32cb75 Mon Sep 17 00:00:00 2001 From: howdeploy Date: Tue, 29 Sep 2026 20:08:42 +0300 Subject: [PATCH 2/4] fix(windows): resolve explicit executable suffixes and inline private paths --- src/main/services/EnvironmentRegistry.ts | 2 +- src/main/services/safety/commandFacts.ts | 4 ++-- tests/base-protection-app-private.test.mjs | 2 +- tests/session-environments.test.mjs | 12 ++++++++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/services/EnvironmentRegistry.ts b/src/main/services/EnvironmentRegistry.ts index 2015ba94..f9c3d2e8 100644 --- a/src/main/services/EnvironmentRegistry.ts +++ b/src/main/services/EnvironmentRegistry.ts @@ -346,7 +346,7 @@ export function resolveCommand(command: string, path: string | undefined, platfo if (command.includes("\u0000")) return null; if (isAbsolute(command)) return isExecutable(command, platform) ? command : null; if (!BARE_COMMAND.test(command)) return null; - const extensions = platform === "win32" ? [".exe", ".com"] : [""]; + const extensions = platform === "win32" && !/\.(?:exe|com)$/iu.test(command) ? [".exe", ".com"] : [""]; for (const directory of (path ?? "").split(platform === "win32" ? ";" : delimiter)) { if (!directory || !isAbsolute(directory)) continue; for (const extension of extensions) { diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index 70713538..0c642a09 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -566,9 +566,9 @@ function privateCandidates(text: string, ctx: PathContext): string[] { if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); const found = new Set(); const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; - for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)[^\s,;'"`()<>|&]*)/gu)) add(match[1]); + for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)[^\s,;'"`()<>|&]*)/gu)) add(match[1]); for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); - for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)/u.test(token)) add(token); + for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)/u.test(token)) add(token); return [...found]; } diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index e79b330d..82b12f31 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -68,7 +68,7 @@ const DENY = [ `curl --unix-socket ${sock} http://localhost/`, `curl -s --unix-socket=${sock} http://x/`, `nc -U ${sock}`, - "echo '{\"v\":1}' | nc -U /tmp/ctty-orch-501-abcd1234/o.sock", + "echo '{\"v\":1}' | nc -U $TMPDIR/ctty-orch-501-abcd1234/o.sock", `socat - UNIX-CONNECT:${sock}`, "ls $TMPDIR/ctty-control-*", "cat $TMPDIR/ctty-*/c.sock", diff --git a/tests/session-environments.test.mjs b/tests/session-environments.test.mjs index 1d52bb85..17828ca6 100644 --- a/tests/session-environments.test.mjs +++ b/tests/session-environments.test.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join, sep } from "node:path"; import test from "node:test"; import { EnvironmentRegistry, resolveCommand } from "../src/main/services/EnvironmentRegistry.ts"; import { validatePluginManifest } from "../src/main/services/PluginManager.ts"; @@ -179,7 +179,7 @@ test("wrap output is validated: program, no shell string, env rules, secrets, cw }).registry.wrap(environment, request); const bare = await wrap({ command: basename(process.execPath), args: ["-e", "true"] }); - assert.equal(bare.ok, true); + assert.equal(bare.ok, true, JSON.stringify(bare)); assert.equal(bare.command, resolveCommand(basename(process.execPath), dirname(process.execPath))); assert.ok(isAbsolute(bare.command)); assert.equal(bare.cwd, cwd); @@ -416,11 +416,15 @@ test("the env-worktree example: a terminal in a real git worktree, restored in i const removed = first.manager.create({ provider: "terminal", profile: "normal", cwd: join(repo, "sub"), position: at, environment: worktree }); const kept = first.manager.create({ provider: "terminal", profile: "normal", cwd: repo, position: at, environment: { ...worktree, options: { branch: "feature/kept" } } }); - await waitFor(() => first.calls.length === 2, 15_000); + await waitFor(() => { + assert.equal(card(first.manager, removed.id).failureDetails, null); + assert.equal(card(first.manager, kept.id).failureDetails, null); + return first.calls.length === 2; + }, 15_000); const byCwd = (manager, id) => card(manager, id).cwd; const removedDir = byCwd(first.manager, removed.id); const keptDir = byCwd(first.manager, kept.id); - assert.ok(removedDir.startsWith(join(dataDir, "worktrees")) && removedDir.endsWith("/sub")); + assert.ok(removedDir.startsWith(join(dataDir, "worktrees")) && removedDir.endsWith(`${sep}sub`)); assert.deepEqual(first.calls.map((call) => call.options.cwd).sort(), [keptDir, removedDir].sort()); assert.equal(execFileSync("git", ["-C", keptDir, "rev-parse", "--abbrev-ref", "HEAD"]).toString().trim(), "feature/kept"); await waitFor(() => card(first.manager, kept.id).environment?.label === "worktree feature/kept"); From 9229230ea71992f6dae5590289db90b8009cfb3e Mon Sep 17 00:00:00 2001 From: howdeploy Date: Tue, 29 Sep 2026 20:32:43 +0300 Subject: [PATCH 3/4] fix: resolve Git worktree roots before comparing paths --- .../plugins/env-worktree/services/worktree.mjs | 2 +- tests/session-environments.test.mjs | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/plugins/env-worktree/services/worktree.mjs b/examples/plugins/env-worktree/services/worktree.mjs index 598bbd6c..13698162 100644 --- a/examples/plugins/env-worktree/services/worktree.mjs +++ b/examples/plugins/env-worktree/services/worktree.mjs @@ -35,7 +35,7 @@ async function prepare({ sessionId, cwd, options }) { return { refuse: { reason: `git worktree add failed: ${String(error.stderr || error.message).trim().slice(0, 200)}` } }; } // The card's folder inside the repository (git reports real paths, so compare real paths). - const inside = relative(repo, realpathSync(cwd)); + const inside = relative(realpathSync(repo), realpathSync(cwd)); const sub = inside.startsWith("..") ? "" : inside; return { ref: { repo, dir, branch, createdBranch: !exists, sub }, label: `worktree ${branch}`, cwd: join(dir, sub) }; } diff --git a/tests/session-environments.test.mjs b/tests/session-environments.test.mjs index 17828ca6..b6076d04 100644 --- a/tests/session-environments.test.mjs +++ b/tests/session-environments.test.mjs @@ -6,7 +6,7 @@ import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, sep } from "node:path"; -import test from "node:test"; +import test, { after } from "node:test"; import { EnvironmentRegistry, resolveCommand } from "../src/main/services/EnvironmentRegistry.ts"; import { validatePluginManifest } from "../src/main/services/PluginManager.ts"; import { PluginServiceSupervisor } from "../src/main/services/PluginServiceSupervisor.ts"; @@ -21,6 +21,15 @@ const cwd = process.cwd(); const at = { x: 0, y: 0 }; const choice = { pluginId: PLUGIN, kind: "box" }; +after(() => { + setTimeout(() => { + console.error("Environment test resources still open:", process.getActiveResourcesInfo()); + console.error("Environment test handles:", process._getActiveHandles().map((handle) => ({ + type: handle.constructor.name, pid: handle.pid, spawnargs: handle.spawnargs + }))); + }, 10_000).unref(); +}); + const waitFor = async (predicate, timeoutMs = 5_000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -424,7 +433,8 @@ test("the env-worktree example: a terminal in a real git worktree, restored in i const byCwd = (manager, id) => card(manager, id).cwd; const removedDir = byCwd(first.manager, removed.id); const keptDir = byCwd(first.manager, kept.id); - assert.ok(removedDir.startsWith(join(dataDir, "worktrees")) && removedDir.endsWith(`${sep}sub`)); + assert.ok(removedDir.startsWith(join(dataDir, "worktrees")) && removedDir.endsWith(`${sep}sub`), + JSON.stringify({ removedDir, dataDir })); assert.deepEqual(first.calls.map((call) => call.options.cwd).sort(), [keptDir, removedDir].sort()); assert.equal(execFileSync("git", ["-C", keptDir, "rev-parse", "--abbrev-ref", "HEAD"]).toString().trim(), "feature/kept"); await waitFor(() => card(first.manager, kept.id).environment?.label === "worktree feature/kept"); From 0ab02f6e4950944228795af0c999913cd68525c1 Mon Sep 17 00:00:00 2001 From: howdeploy Date: Tue, 29 Sep 2026 20:38:51 +0300 Subject: [PATCH 4/4] fix: canonicalize Windows short paths and order test cleanup --- examples/plugins/env-worktree/services/worktree.mjs | 2 +- tests/launch-contributors.test.mjs | 2 +- tests/session-environments.test.mjs | 11 +---------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/examples/plugins/env-worktree/services/worktree.mjs b/examples/plugins/env-worktree/services/worktree.mjs index 13698162..acb1333f 100644 --- a/examples/plugins/env-worktree/services/worktree.mjs +++ b/examples/plugins/env-worktree/services/worktree.mjs @@ -35,7 +35,7 @@ async function prepare({ sessionId, cwd, options }) { return { refuse: { reason: `git worktree add failed: ${String(error.stderr || error.message).trim().slice(0, 200)}` } }; } // The card's folder inside the repository (git reports real paths, so compare real paths). - const inside = relative(realpathSync(repo), realpathSync(cwd)); + const inside = relative(realpathSync.native(repo), realpathSync.native(cwd)); const sub = inside.startsWith("..") ? "" : inside; return { ref: { repo, dir, branch, createdBranch: !exists, sub }, label: `worktree ${branch}`, cwd: join(dir, sub) }; } diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index ee1cab0a..5f71ebba 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -326,7 +326,6 @@ test("options persist with the session, restart reuses them, and restore asks th test("Reopen with a launch plugin starts fresh and forgets the old conversation", async (t) => { const conversation = "5f1c2a90-aa11-4b22-9c33-0d44e55f6677"; const directory = await mkdtemp(join(tmpdir(), "canvastty-launch-reopen-")); - t.after(() => rm(directory, { recursive: true, force: true })); await new TerminalSessionStore(directory).replace([{ id: "reopened", provider: "claude", profile: "normal", role: "agent", title: "Agent", titleCustomized: false, cwd, position: at, size: { width: 700, height: 430 }, lastState: "running", restore: true, @@ -334,6 +333,7 @@ test("Reopen with a launch plugin starts fresh and forgets the old conversation" }]); const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.one")], answers: { "p.one": {} } }); const { manager, calls } = await managerFixture(t, pipeline, { mode: "reopen", directory }); + t.after(() => rm(directory, { recursive: true, force: true })); await waitFor(() => calls.length === 1); assert.equal(calls[0].args.includes(conversation), false); await manager.setSessionRestoreMode("continue"); diff --git a/tests/session-environments.test.mjs b/tests/session-environments.test.mjs index b6076d04..62616b6c 100644 --- a/tests/session-environments.test.mjs +++ b/tests/session-environments.test.mjs @@ -6,7 +6,7 @@ import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, sep } from "node:path"; -import test, { after } from "node:test"; +import test from "node:test"; import { EnvironmentRegistry, resolveCommand } from "../src/main/services/EnvironmentRegistry.ts"; import { validatePluginManifest } from "../src/main/services/PluginManager.ts"; import { PluginServiceSupervisor } from "../src/main/services/PluginServiceSupervisor.ts"; @@ -21,15 +21,6 @@ const cwd = process.cwd(); const at = { x: 0, y: 0 }; const choice = { pluginId: PLUGIN, kind: "box" }; -after(() => { - setTimeout(() => { - console.error("Environment test resources still open:", process.getActiveResourcesInfo()); - console.error("Environment test handles:", process._getActiveHandles().map((handle) => ({ - type: handle.constructor.name, pid: handle.pid, spawnargs: handle.spawnargs - }))); - }, 10_000).unref(); -}); - const waitFor = async (predicate, timeoutMs = 5_000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) {