Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 1.7.0

- Windows 编排启动改用现有的仅限当前用户访问的管道服务,并修复私有数据保护对展开后的 Windows 路径的识别。完整 Windows 测试现在会在 PR 合并前运行,而不再仅在发布打包时运行。

- 集成 PR #98 的像素终端皮肤及智能体生成的主题包,画布背景与终端边框可以独立选择。主题创建界面新增简短说明、上传槽位标签、示例和预览提示;画布图案位于背景选择之前,并说明背景图片何时会覆盖图案。
- 恢复缩小时可读的终端摘要卡片,为编排者自动使用 Master 皮肤,并恢复像素窗口的边缘和角落缩放,不再重置手动调整或恢复的尺寸。
- 集成 PR #100,修复启动导航竞态、API 密钥粘贴及未捕获界面错误后的恢复,并保护 CanvasTTY 的私有控制数据。该 PR 汇总了 #96、#97 和 #99 的修复。
Expand Down
2 changes: 1 addition & 1 deletion examples/plugins/env-worktree/services/worktree.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.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) };
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,11 @@ async function initializeServices(): Promise<void> {
// 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();
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/EnvironmentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
60 changes: 44 additions & 16 deletions src/main/services/agent-browser/OrchestrationGateway.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,6 +32,8 @@ import {
tokenMatches
} from "../gatewaySocket.ts";

import { WindowsPipeHostTransport, type AgentGatewaySocket } from "./WindowsPipeHostTransport.ts";

const CAPABILITY_TTL_MS = 60_000;

interface CapabilityLease {
Expand All @@ -47,7 +49,7 @@ interface CapabilityLease {
}

interface Connection {
socket: Socket;
socket: AgentGatewaySocket;
decoder: OrchestrationNdjsonDecoder;
lease: CapabilityLease | null;
authenticated: boolean;
Expand All @@ -59,6 +61,7 @@ interface Connection {

export interface OrchestrationGatewayOptions {
runtimeDirectory: string;
windowsHostPath?: string;
handler: OrchestrationCommandHandler;
capabilityTtlMs?: number;
heartbeatIntervalMs?: number;
Expand All @@ -72,6 +75,8 @@ export class OrchestrationGateway {
private readonly connections = new Set<Connection>();
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;
Expand All @@ -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;
Expand All @@ -109,19 +115,38 @@ export class OrchestrationGateway {

async start(): Promise<void> {
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?.();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/main/services/safety/commandFacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,17 +558,17 @@ 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. */
function privateCandidates(text: string, ctx: PathContext): string[] {
if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT);
const found = new Set<string>();
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];
}

Expand Down
2 changes: 1 addition & 1 deletion tests/base-protection-app-private.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/base-protection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
}
Expand Down
8 changes: 4 additions & 4 deletions tests/claude-http-hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand All @@ -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);
Expand All @@ -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" } });
Expand Down
2 changes: 1 addition & 1 deletion tests/decision-hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions tests/launch-contributors.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -325,14 +326,14 @@ 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,
threadId: conversation, options: { "p.one": { on: true } }
}]);
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");
Expand Down Expand Up @@ -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 }));
Expand Down
Loading