diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index e37a8946..681d7ff9 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -308,7 +308,11 @@ export interface CanvasTTYDecisionRequest { /** The agent's current folder, when its CLI reports it. */ agentCwd: string | null; tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; - /** The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). */ + /** + * The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). OpenCode's tools come in Claude + * Code's names and shapes: bash {command, workdir}, Write {file_path, content}, Edit {file_path, old_string, new_string, + * replace_all}, MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]}, apply_patch {patch}. + */ input: unknown; truncated: boolean; /** How long the host waits for this answer (the service's `timeoutMs`). */ diff --git a/docs/plugins.md b/docs/plugins.md index 86636751..1a565b82 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -286,7 +286,7 @@ Before a local agent's shell command or file write runs, CanvasTTY can ask a plu }] ``` -`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: +`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). OpenCode's tools arrive under Claude Code's names and input shapes: `bash` `{command, workdir}`, `Write` `{file_path, content}` (the whole new file), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` and `apply_patch` `{patch}`. `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: ```ts interface DecisionRequest { diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 9b46a0b3..506c4eb8 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -264,7 +264,7 @@ CanvasTTY владеет окном, PTY, сохранённой записью }] ``` -`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`: +`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). Инструменты OpenCode приходят с именами и формой ввода Claude Code: `bash` `{command, workdir}`, `Write` `{file_path, content}` (весь новый файл), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` и `apply_patch` `{patch}`. `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`: ```ts interface DecisionRequest { diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 21651e19..cce01bb1 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -262,7 +262,7 @@ CanvasTTY 负责卡片、PTY、保存的记录和恢复顺序;服务回答五 }] ``` -`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算: +`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。OpenCode 的工具以 Claude Code 的名称和输入形状到达:`bash` `{command, workdir}`、`Write` `{file_path, content}`(整个新文件)、`Edit` `{file_path, old_string, new_string, replace_all}`、`MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` 和 `apply_patch` `{patch}`。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算: ```ts interface DecisionRequest { diff --git a/src/agent-runtime/opencode-decisions.mjs b/src/agent-runtime/opencode-decisions.mjs index d9e9c4ca..f3ec617e 100644 --- a/src/agent-runtime/opencode-decisions.mjs +++ b/src/agent-runtime/opencode-decisions.mjs @@ -62,9 +62,13 @@ export function createOpenCodeDecisions({ client, env = process.env, send = exch } /** - * An OpenCode tool call in the shape the decision hook reads (the Claude/Codex hook tool names), or null for a tool - * that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write {filePath, - * content}, edit {filePath, oldString, newString}, multiedit {filePath, edits}, apply_patch {patchText}. + * An OpenCode tool call in the shape the decision hook reads (Claude Code's hook tool names and input shapes), or null + * for a tool that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write + * {filePath, content}, edit {filePath, oldString, newString, replaceAll}, multiedit {filePath, edits: [{filePath, + * oldString, newString, replaceAll}]}, apply_patch {patchText}. They go out as bash {command, workdir}, Write {file_path, + * content} (the whole new file), Edit {file_path, old_string, new_string, replace_all} (one replacement in the file), + * MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]} and apply_patch {patch}, so a decision service + * can tell a whole-file write from a partial edit and sees the text an edit replaces. */ export function guardedCall(tool, args) { if (!tool || !args || typeof args !== "object") return null; @@ -74,13 +78,15 @@ export function guardedCall(tool, args) { return { toolName: "bash", toolInput: { command: args.command, ...(workdir ? { workdir } : {}) } }; } if (tool === "write" || tool === "edit" || tool === "multiedit") { - const filePath = typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath - : typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null; + const filePath = pathOf(args); if (!filePath) return null; - const content = typeof args.content === "string" ? args.content - : typeof args.newString === "string" ? args.newString - : multieditText(args.edits); - return { toolName: "edit", toolInput: { file_path: filePath, ...(content !== null ? { content } : {}) } }; + if (tool === "write") { + return { toolName: "Write", toolInput: { file_path: filePath, ...(typeof args.content === "string" ? { content: args.content } : {}) } }; + } + if (tool === "edit") return { toolName: "Edit", toolInput: { file_path: filePath, ...replacement(args) } }; + const edits = Array.isArray(args.edits) ? args.edits.filter((edit) => edit && typeof edit === "object").map(replacement) + .filter((edit) => Object.keys(edit).length > 0) : []; + return { toolName: "MultiEdit", toolInput: { file_path: filePath, edits } }; } if (tool === "apply_patch" || tool === "patch") { const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null; @@ -89,11 +95,22 @@ export function guardedCall(tool, args) { return null; } -/** Every multiedit edit's new text, one per line (what the batch writes), or null. */ -function multieditText(edits) { - if (!Array.isArray(edits)) return null; - const texts = edits.flatMap((edit) => edit && typeof edit === "object" && typeof edit.newString === "string" ? [edit.newString] : []); - return texts.length > 0 ? texts.join("\n") : null; +function pathOf(args) { + return typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath + : typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null; +} + +/** One OpenCode replacement {oldString, newString, replaceAll} as Claude's Edit input (fields that are not text are left out). */ +function replacement(edit) { + const text = (camel, snake) => typeof edit[camel] === "string" ? edit[camel] : typeof edit[snake] === "string" ? edit[snake] : null; + const oldString = text("oldString", "old_string"); + const newString = text("newString", "new_string"); + const replaceAll = typeof edit.replaceAll === "boolean" ? edit.replaceAll : typeof edit.replace_all === "boolean" ? edit.replace_all : null; + return { + ...(oldString !== null ? { old_string: oldString } : {}), + ...(newString !== null ? { new_string: newString } : {}), + ...(replaceAll !== null ? { replace_all: replaceAll } : {}) + }; } /** POST /permission/{requestID}/reply with `once`. False on any failure (OpenCode's prompt stays for the person). */ diff --git a/src/main/index.ts b/src/main/index.ts index 03fabc25..dd23c6d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -536,7 +536,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { providerClis, runtimeDirectory, hermesHomeDirectory, - kimiHomeDirectory + kimiHomeDirectory, + accountHomesRoot: join(userDataPath, "plugin-data") }); // Off the startup path: the first Kimi launch then finds the probe answered instead of blocking on it. const bridge = agentBrowserBridge; @@ -588,6 +589,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { openCodePluginPath, hermesHomeDirectory, kimiHomeDirectory, + // Account homes a launch contributor starts Grok/Kimi with live in plugin data (ProviderRuntimeLaunch.ownAccountHome). + accountHomesRoot: join(userDataPath, "plugin-data"), recoverOnStart: true, coreHooksEnabled: settings.get().agentLifecycleHooksEnabled, permissionGate: helpers.permissionGate, diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a5..aa74f268 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -12,6 +12,7 @@ import type { import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } from "./terminalLaunch.ts"; import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; +import { contributedHookHome } from "./accountHomes.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ @@ -22,6 +23,8 @@ export interface LaunchContributor { launch: PluginServiceLaunch; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; + /** `/plugin-data/`, the host's record (never the plugin's answer); account homes live below it. */ + dataDir?: string; } export interface LaunchPipelineDependencies { @@ -73,6 +76,11 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** + * Grok/Kimi: the home (`GROK_HOME` / `KIMI_CODE_HOME`) the contributor that set it handed this launch inside its own + * data folder (contributedHookHome); CanvasTTY writes that CLI's temporary configuration there. + */ + accountHome?: string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -267,6 +275,7 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + let accountHome: string | undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } @@ -313,6 +322,7 @@ export class LaunchPipeline { const conflict = claim(key); if (conflict) return refuse(conflict); env[key] = expand(value); + accountHome = contributedHookHome(context.provider, contributor.dataDir, key, env[key]) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +337,7 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, ...(accountHome ? { accountHome } : {}), cleanup }; } private async ask( diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index a6ccaa39..43151b27 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -674,7 +674,8 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), - secrets: manifest.permissions.includes("secrets") + secrets: manifest.permissions.includes("secrets"), + dataDir: join(this.dataRoot, plugin.manifest.id) }); } return contributors; diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 03d2417e..40040ec8 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -58,6 +58,7 @@ import { type ControlConnection } from "./agent-control/controlCapabilities.ts"; import { codexTrustArguments, mergeOpenCodeLaunchEnvironment } from "./agent-runtime/ProviderRuntimeLaunch.ts"; +import { accountHookHome } from "./accountHomes.ts"; import { openCodeProjectFolderEnvironment } from "./openCodeConfig.ts"; import { onDiskPath } from "./onDiskPath.ts"; import { processWorkingDirectory } from "./processWorkingDirectory.ts"; @@ -2071,10 +2072,15 @@ export class TerminalManager { cwd } : undefined; const shellCli = provider === "terminal" && !environmentWrapped ? this.providerClis.get("codex") : undefined; + // Grok and Kimi read their hooks (Kimi also its MCP servers) from their home: the launch's own account home (inside + // the data folder of the plugin that set it) gets CanvasTTY's temporary configuration there. + const hookHome = accountHookHome(provider, contribution?.env); + const accountHome = hookHome !== undefined && hookHome === contribution?.accountHome ? hookHome : undefined; const agentRuntime = provider === "terminal" ? shellCli?.state === "available" ? this.agentRuntime?.prepareShellLaunch?.({ terminalSessionId: id, cli: shellCli, environment: terminalEnvironment() }) ?? null : null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, ...(captureResult ? { captureResult: true } : {}), + ...(accountHome ? { accountHome } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; @@ -2106,6 +2112,7 @@ export class TerminalManager { terminalSessionId: id, provider, cwd, + ...(accountHome ? { accountHome } : {}), ...(bridged ? { includeOrchestration: true, orchestrationTools: [...(role === "orchestrator" ? ORCHESTRATION_TOOL_NAMES : []), ...pluginTools] diff --git a/src/main/services/accountHomes.ts b/src/main/services/accountHomes.ts new file mode 100644 index 00000000..16fd76e5 --- /dev/null +++ b/src/main/services/accountHomes.ts @@ -0,0 +1,108 @@ +import { lstatSync, mkdirSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { ProviderId } from "../../shared/contracts.ts"; + +// Account homes: a CLI home a launch contributor starts Grok or Kimi with (an account of an account pool). Grok Build +// and Kimi Code read their hooks, and Kimi its MCP servers and permission rules, only from that home, so CanvasTTY's +// temporary overlays go there for such a launch. These are the rules for when a home counts as the launch's own. + +/** The variable that moves the home whose configuration Grok Build / Kimi Code read. */ +const ACCOUNT_HOME_VARIABLES: Readonly>> = { grok: "GROK_HOME", kimi: "KIMI_CODE_HOME" }; + +/** A folder path without trailing separators (`/a/b/` names the same folder as `/a/b`); a root stays as it is. */ +export function withoutTrailingSeparator(path: string): string { + const trimmed = path.replace(/[\\/]+$/u, ""); + return trimmed === "" || /^[A-Za-z]:$/u.test(trimmed) ? path : trimmed; +} + +/** + * The path segments of `path` strictly below `root`, or null when it is not strictly inside. Only a whole `..` segment + * leaves the root; a folder whose name merely starts with two dots (`..cache`) is inside it. + */ +function segmentsBelow(root: string, path: string): string[] | null { + const rest = relative(root, path); + if (rest === "" || rest === ".." || rest.startsWith(`..${sep}`) || isAbsolute(rest)) return null; + return rest.split(sep); +} + +/** + * The home a launch contribution started a Grok or Kimi launch with (its `GROK_HOME` / `KIMI_CODE_HOME`); undefined for + * other providers or when the contribution leaves the home alone. `KIMI_SHARE_DIR` (the older kimi-cli) is not it. + */ +export function accountHookHome(provider: ProviderId, contributedEnvironment: Readonly> | undefined): string | undefined { + const name = ACCOUNT_HOME_VARIABLES[provider]; + const value = name ? contributedEnvironment?.[name] : undefined; + return typeof value === "string" && value.length > 0 ? withoutTrailingSeparator(value) : undefined; +} + +/** + * The account home one contributor handed a Grok/Kimi launch: `value` of the provider's home variable `key` when it is an + * exact absolute path strictly inside that contributor's own data folder. `dataDir` is the host's record of the + * contributor (`/plugin-data/`), never the plugin's answer, so a plugin can only name a folder of + * its own data. + */ +export function contributedHookHome(provider: ProviderId, dataDir: string | undefined, key: string, value: string): string | undefined { + if (!dataDir || !isAbsolute(dataDir) || ACCOUNT_HOME_VARIABLES[provider] !== key) return undefined; + const folder = withoutTrailingSeparator(value); + if (!isAbsolute(folder) || resolve(folder) !== folder) return undefined; + return segmentsBelow(resolve(dataDir), folder) ? folder : undefined; +} + +/** + * The account home an overlay may be written into, or null for the default home (the launch is then exactly as before). + * Only an exact absolute folder at least `/` deep inside `root` (`/plugin-data`), other than the + * default home, counts. Inside the root every folder from the root down must be a real folder (a missing one is created, + * private) and none of `written` (names in the home CanvasTTY writes) a symbolic link, or this throws and the launch is + * refused: CanvasTTY never writes through a link into another place. + */ +export function ownedAccountHome(options: { + root: string | undefined; + candidate: string | undefined; + defaultHome: string; + written: readonly string[]; + label: string; +}): string | null { + const { root, candidate } = options; + if (!root || candidate === undefined) return null; + const home = withoutTrailingSeparator(candidate); + // The default home as configured may carry a trailing separator (`KIMI_CODE_HOME=/a/b/`); it names the same folder. + if (!isAbsolute(home) || resolve(home) !== home || home === resolve(options.defaultHome)) return null; + const segments = segmentsBelow(resolve(root), home); + if (!segments || segments.length < 2) return null; + try { + let path = resolve(root); + let canonical = realpathSync.native(path); + for (const segment of ["", ...segments]) { + if (segment) { + path = join(path, segment); + canonical = join(canonical, segment); + } + // A new account's home (or the folders above it in the plugin's data) may not exist yet: the CLI would create it + // on its first run. One private folder at a time, never recursively, so no link is followed while creating it. + if (segment && !exists(path)) mkdirSync(path, { mode: 0o700 }); + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) throw new Error("not a real folder"); + } + } catch { + throw new Error(`The ${options.label} account home is missing or crosses a symbolic link; CanvasTTY did not write its configuration there.`); + } + for (const name of options.written) { + let link = false; + try { + link = lstatSync(join(home, name)).isSymbolicLink(); + } catch { + continue; + } + if (link) throw new Error(`The ${options.label} account home holds a symbolic link where CanvasTTY writes its configuration.`); + } + return home; +} + +function exists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} diff --git a/src/main/services/agent-browser/AgentBrowserBridge.ts b/src/main/services/agent-browser/AgentBrowserBridge.ts index 06fa4f53..8dae4ac6 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -14,6 +14,8 @@ export interface PrepareAgentBrowserLaunchInput { includeOrchestration?: boolean; /** The canvastty_agents tools this session may use (default: the core tools). */ orchestrationTools?: readonly string[]; + /** Kimi: the launch's own account home (`KIMI_CODE_HOME` inside the contributing plugin's data), for its config. */ + accountHome?: string; } export interface PreparedAgentBrowserPtyLaunch { @@ -71,7 +73,8 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { try { providerLaunch = this.providers.prepare(input.provider, capability.connectionId, { ...(input.includeOrchestration ? { orchestration: true } : {}), - ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}) + ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), + ...(input.accountHome ? { accountHome: input.accountHome } : {}) }); } catch (error) { this.gateway.revokeTerminalSession(input.terminalSessionId); @@ -93,7 +96,8 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { const providerLaunch = this.providers.prepare(input.provider, connectionId, { orchestration: true, browser: false, - ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}) + ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), + ...(input.accountHome ? { accountHome: input.accountHome } : {}) }); return this.launchWith(providerLaunch, "", connectionId, () => undefined, {}); } diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index f59cbe12..ac7ee3da 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -22,6 +22,7 @@ import { resolveHermesHomeDirectory } from "../hermesConfig.ts"; import { openCodeBrowserEnvironment } from "../openCodeConfig.ts"; +import { ownedAccountHome } from "../accountHomes.ts"; import { acquireConfigurationLock, atomicWrite, @@ -74,6 +75,11 @@ export interface ProviderLaunchOptions { /** The same probe off the main thread, for warmKimiProbe; defaults to the sync probe's answer when only that is given. */ probeKimiPerRunConfigAsync?: (cli: AvailableProviderCli) => Promise; environment?: Readonly>; + /** + * `/plugin-data`: where an account home (the `KIMI_CODE_HOME` a launch contributor started Kimi with) may be + * for Kimi's temporary configuration to be written into it (accountHomes.ts). Without it no account home gets it. + */ + accountHomesRoot?: string; } interface KimiLockHooks extends ConfigurationLockHooks { @@ -95,8 +101,8 @@ export class ProviderLaunchAdapters { private kimiWarmed: { executable: string; generation: number; result: boolean } | null = null; private kimiWarming: Promise | null = null; private kimiGeneration = 0; - private kimiConfiguration: KimiTemporaryConfiguration | null = null; - private kimiConfigurationUsers = 0; + /** The temporary Kimi configuration per Kimi home (the default home and account homes) and its launches. */ + private readonly kimiConfigurations = new Map(); private hermesConfiguration: HermesTemporaryConfiguration | null = null; private hermesConfigurationUsers = 0; @@ -108,6 +114,9 @@ export class ProviderLaunchAdapters { this.kimiHomeDirectory = validateKimiHomeDirectory( options.kimiHomeDirectory ?? join(homedir(), ".kimi-code") ); + if (options.accountHomesRoot !== undefined && !isAbsolute(options.accountHomesRoot)) { + throw new Error("Account homes root must be absolute."); + } this.environment = options.environment ?? process.env; // The probe runs Kimi with this adapter's environment (a fake HOME, an account's), never the app's own. this.probe = options.probeKimiPerRunConfig ?? ((cli) => probeKimiPerRunMcpConfig(cli, undefined, this.environment)); @@ -148,9 +157,11 @@ export class ProviderLaunchAdapters { /** * `orchestrationTools`: the canvastty_agents tools this session may use (default: the core tools). `browser: false` - * (browser access off) leaves canvastty_browser out, so only canvastty_agents is attached. + * (browser access off) leaves canvastty_browser out, so only canvastty_agents is attached. `accountHome` (Kimi only): + * the `KIMI_CODE_HOME` a launch contributor started this launch with; Kimi's temporary configuration goes there when + * it is an account home (accountHomes.ts), since that is the only home this Kimi reads. */ - prepare(provider: AgentProvider, connectionId: string, options?: { orchestration?: boolean; orchestrationTools?: readonly string[]; browser?: boolean }): PreparedProviderLaunch { + prepare(provider: AgentProvider, connectionId: string, options?: { orchestration?: boolean; orchestrationTools?: readonly string[]; browser?: boolean; accountHome?: string }): PreparedProviderLaunch { const providerCli = this.providerClis.get(provider); if (providerCli.state === "unavailable") throw new Error(providerCli.diagnostic); const orchestrationHelper = orchestrationHelperFor(this.options, options); @@ -192,7 +203,14 @@ export class ProviderLaunchAdapters { releaseConfiguration: this.acquireHermesConfiguration(browserHelper, orchestrationHelper) }; } - return this.prepareKimi(connectionId, browserHelper, orchestrationHelper); + const home = ownedAccountHome({ + root: this.options.accountHomesRoot, + candidate: options?.accountHome, + defaultHome: this.kimiHomeDirectory, + written: KIMI_WRITTEN, + label: "Kimi" + }) ?? this.kimiHomeDirectory; + return this.prepareKimi(connectionId, browserHelper, orchestrationHelper, home); } recoverKimiConfiguration(): void { @@ -234,7 +252,7 @@ export class ProviderLaunchAdapters { }; } - private prepareKimi(connectionId: string, browserHelper: StdioHelperLaunch | null, orchestrationHelper?: StdioHelperLaunch): PreparedProviderLaunch { + private prepareKimi(connectionId: string, browserHelper: StdioHelperLaunch | null, orchestrationHelper: StdioHelperLaunch | undefined, home: string): PreparedProviderLaunch { const kimiCli = this.providerClis.get("kimi"); if (kimiCli.state === "unavailable") throw new Error(kimiCli.diagnostic); if (this.kimiProbedExecutable !== kimiCli.executable) { @@ -252,6 +270,7 @@ export class ProviderLaunchAdapters { // The per-run document carries orchestration per launch, so the shared // fallback configuration only tracks it when mcp.json is actually mutated. const releaseShared = this.acquireKimiConfiguration( + home, !supportsPerRun, browserHelper, supportsPerRun ? undefined : orchestrationHelper @@ -286,6 +305,7 @@ export class ProviderLaunchAdapters { } private acquireKimiConfiguration( + home: string, includeMcpEntry: boolean, browserHelper: StdioHelperLaunch | null, orchestrationHelper?: StdioHelperLaunch @@ -293,32 +313,34 @@ export class ProviderLaunchAdapters { const requiresOrchestration = includeMcpEntry && orchestrationHelper !== undefined; // The shared mcp.json carries the browser entry only while browser access is on. const includeBrowser = includeMcpEntry && browserHelper !== null; - if (!this.kimiConfiguration) { - this.kimiConfiguration = KimiTemporaryConfiguration.begin({ - homeDirectory: this.kimiHomeDirectory, + let shared = this.kimiConfigurations.get(home); + if (!shared) { + shared = { configuration: KimiTemporaryConfiguration.begin({ + homeDirectory: home, helper: includeBrowser ? browserHelper : null, includeMcpEntry, ...(requiresOrchestration && orchestrationHelper ? { orchestrationHelper } : {}) - }); - } else if (this.kimiConfiguration.includeMcpEntry !== includeMcpEntry) { + }), users: 0 }; + this.kimiConfigurations.set(home, shared); + } else if (shared.configuration.includeMcpEntry !== includeMcpEntry) { throw new Error("Kimi MCP capability changed while temporary configuration is active."); - } else if (includeMcpEntry && this.kimiConfiguration.hasBrowserEntry !== includeBrowser) { + } else if (includeMcpEntry && shared.configuration.hasBrowserEntry !== includeBrowser) { throw new Error("Kimi browser access cannot change while a temporary Kimi configuration is active."); - } else if (requiresOrchestration && !this.kimiConfiguration.hasOrchestrationEntry) { + } else if (requiresOrchestration && !shared.configuration.hasOrchestrationEntry) { // Mirrors the Hermes guard: never silently drop the canvastty_agents // entry an orchestrator needs because a plain launch owns the config. throw new Error("Kimi MCP orchestration cannot be enabled while a temporary Kimi configuration is active."); } - this.kimiConfigurationUsers += 1; + const owned = shared; + owned.users += 1; let released = false; return () => { if (released) return; released = true; - this.kimiConfigurationUsers -= 1; - if (this.kimiConfigurationUsers !== 0) return; - const configuration = this.kimiConfiguration; - this.kimiConfiguration = null; - configuration?.cleanup(); + owned.users -= 1; + if (owned.users !== 0) return; + if (this.kimiConfigurations.get(home) === owned) this.kimiConfigurations.delete(home); + owned.configuration.cleanup(); }; } } @@ -867,6 +889,9 @@ function parseJournal(raw: string, paths: ReturnType): Recover return value as unknown as RecoveryJournal; } +/** What KimiTemporaryConfiguration writes in a Kimi home (kimiPaths); none may be a symbolic link in an account home. */ +const KIMI_WRITTEN = ["mcp.json", "config.toml", ".canvastty-browser.lock", ".canvastty-browser-recovery.json", ".canvastty-browser-backups"]; + function kimiPaths(homeDirectory: string) { return { mcp: join(homeDirectory, "mcp.json"), diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index f7482468..864368bf 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -28,6 +28,8 @@ export interface PrepareAgentRuntimeLaunchInput { decisions?: boolean; /** Claude Code: what decides whether its lifecycle hooks may go over HTTP (see ClaudeHttpHooks.ts). */ claudeHttp?: ClaudeHttpLaunchFacts; + /** Grok/Kimi: the CLI home a launch contributor started the launch with (`accountHookHome`), for its status hooks. */ + accountHome?: string; } export interface PreparedAgentRuntimePtyLaunch { @@ -143,7 +145,7 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined); + httpHookBase ?? undefined, input.accountHome); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId, capability.capabilityToken); throw error; diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 426ab424..beafb57a 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -11,6 +11,7 @@ import { dirname, isAbsolute, join, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { lazyRequire } from "../../lazyRequire.ts"; import { isOwnLifecycleHookText } from "../agentHelpers.ts"; +import { ownedAccountHome } from "../accountHomes.ts"; import { atomicWrite, ensurePrivateDirectory, @@ -93,6 +94,11 @@ export interface ProviderRuntimeLaunchOptions { pluginHooks?: RuntimePluginHookSource; /** The decision hook (permission-gate.mjs); without it decision hooks are never installed. */ permissionGate?: RuntimeHookHelperLaunch; + /** + * `/plugin-data`: the only place an account home (a CLI home a launch contributor started the launch with) + * may be for the Grok/Kimi lifecycle overlays to be written into it. Without it no account home gets them. + */ + accountHomesRoot?: string; } export interface PreparedProviderRuntimeLaunch { @@ -101,6 +107,10 @@ export interface PreparedProviderRuntimeLaunch { releaseConfiguration(): void; } +const KIMI_JOURNAL = ".canvastty-runtime-kimi.json"; +const RUNTIME_BACKUPS = ".canvastty-runtime-backups"; +const GROK_HOOKS_FILE = "canvastty-runtime-hooks.json"; + const HOOK_PROVIDERS: ReadonlySet = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp", "pi"]); export class ProviderRuntimeLaunchAdapters { @@ -120,6 +130,8 @@ export class ProviderRuntimeLaunchAdapters { private grokOverlay: GrokRuntimeHooks | null = null; private grokOverlaySignature: string | null = null; private grokUsers = 0; + /** Grok/Kimi overlays in account homes, by provider and home; each is removed when its last launch ends. */ + private readonly accountOverlays = new Map(); constructor(options: ProviderRuntimeLaunchOptions) { validateHelper(options.helper); @@ -129,6 +141,9 @@ export class ProviderRuntimeLaunchAdapters { if (!isAbsolute(options.openCodePluginPath)) { throw new Error("OpenCode lifecycle plugin path must be absolute."); } + if (options.accountHomesRoot !== undefined && !isAbsolute(options.accountHomesRoot)) { + throw new Error("Account homes root must be absolute."); + } if (options.permissionGate) { validateHelper(options.permissionGate); // Either Electron running one absolute script, or the native helper's permission-gate subcommand. @@ -175,6 +190,10 @@ export class ProviderRuntimeLaunchAdapters { * `decisionBudgetMs` (a decision service's `decide.timeoutMs`) lengthens the hook's deadlines to fit it. * `claudeHttpHookBase` (Claude Code only) sends its lifecycle events, except SessionStart, as HTTP hooks to the * gateway's loopback listener instead of running the helper; the decision hook stays a command. + * `accountHome` (Grok and Kimi only, see accountHomes.ts): the CLI home a launch contributor started this launch + * with. When it is an account home inside `accountHomesRoot`, the Grok/Kimi overlay goes there instead of into the + * default home, since that is the only home the CLI reads. Claude Code, Codex, Qwen Code and OpenCode take their hooks + * from the launch's arguments or environment, so they work in any home already. */ prepare( provider: AgentProvider, @@ -182,7 +201,8 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + accountHome?: string ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -244,6 +264,10 @@ export class ProviderRuntimeLaunchAdapters { ? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")] : [], environment); } + if (provider === "kimi" || provider === "grok") { + const ownHome = this.ownAccountHome(provider, accountHome); + if (ownHome) return prepared([], environment, this.acquireAccountHome(provider, ownHome, coreHooksEnabled, pluginCommands)); + } if (provider === "kimi") { return prepared([], environment, this.acquireKimi(coreHooksEnabled, pluginCommands)); } @@ -355,6 +379,60 @@ export class ProviderRuntimeLaunchAdapters { }); } + /** The account home this Grok/Kimi launch's overlay goes into (accountHomes.ts), or null for the default home. */ + private ownAccountHome(provider: "kimi" | "grok", candidate: string | undefined): string | null { + return ownedAccountHome({ + root: this.options.accountHomesRoot, + candidate, + defaultHome: provider === "kimi" ? this.kimiHomeDirectory : this.grokHomeDirectory, + written: provider === "kimi" ? ["config.toml", KIMI_JOURNAL, RUNTIME_BACKUPS] : ["hooks", join("hooks", GROK_HOOKS_FILE)], + label: provider === "kimi" ? "Kimi" : "Grok" + }); + } + + private acquireAccountHome( + provider: "kimi" | "grok", + home: string, + coreHooksEnabled: boolean, + pluginCommands: readonly ProviderHookCommand[] + ): () => void { + const key = `${provider}\0${home}`; + const signature = overlaySignature(coreHooksEnabled, pluginCommands); + let entry = this.accountOverlays.get(key); + if (!entry) { + entry = { overlay: null, signature: null, users: 0 }; + this.accountOverlays.set(key, entry); + } + // A changed hook set replaces the overlay but keeps the home's leases: launches prepared before the change still + // hold this home, so the overlay stays until the last of them, old or new, is released (as for the default home). + if (!entry.overlay || entry.signature !== signature) { + const previous = entry.overlay; + entry.overlay = null; + entry.signature = null; + previous?.cleanup(); + try { + entry.overlay = provider === "kimi" + ? KimiRuntimeHooks.begin(home, this.options.helper, this.platform, coreHooksEnabled, pluginCommands) + : GrokRuntimeHooks.begin(home, this.options.helper, this.platform, coreHooksEnabled, pluginCommands); + } catch (error) { + if (entry.users === 0) this.accountOverlays.delete(key); + throw error; + } + entry.signature = signature; + } + const owned = entry; + owned.users += 1; + return once(() => { + owned.users -= 1; + if (owned.users !== 0) return; + if (this.accountOverlays.get(key) === owned) this.accountOverlays.delete(key); + const overlay = owned.overlay; + owned.overlay = null; + owned.signature = null; + overlay?.cleanup(); + }); + } + /** Whether this provider can take the decision hook: Claude Code, Codex, Qwen Code (PreToolUse) and OpenCode. */ decisionsSupported(provider: AgentProvider): boolean { return Boolean(this.options.permissionGate) && (provider === "opencode" || Object.hasOwn(DECISION_TOOL_MATCHERS, provider)); @@ -794,7 +872,7 @@ class KimiRuntimeHooks { ): KimiRuntimeHooks { ensurePrivateDirectory(home); const path = join(home, "config.toml"); - const journalPath = join(home, ".canvastty-runtime-kimi.json"); + const journalPath = join(home, KIMI_JOURNAL); this.recover(home); const original = readOptional(path); const block = kimiHookBlock(helper, platform, coreHooksEnabled, pluginCommands); @@ -809,7 +887,7 @@ class KimiRuntimeHooks { } static recover(home: string): void { - const journalPath = join(home, ".canvastty-runtime-kimi.json"); + const journalPath = join(home, KIMI_JOURNAL); const journal = readJournal(journalPath); if (!journal) return; const path = join(home, "config.toml"); @@ -895,7 +973,7 @@ class GrokRuntimeHooks { ): GrokRuntimeHooks { const hooksDirectory = join(home, "hooks"); ensurePrivateDirectory(hooksDirectory); - const path = join(hooksDirectory, "canvastty-runtime-hooks.json"); + const path = join(hooksDirectory, GROK_HOOKS_FILE); this.recover(home); if (existsSync(path)) throw new Error("Grok CanvasTTY lifecycle hook path is already occupied."); const expected = `${JSON.stringify({ @@ -909,7 +987,7 @@ class GrokRuntimeHooks { } static recover(home: string): void { - const path = join(home, "hooks", "canvastty-runtime-hooks.json"); + const path = join(home, "hooks", GROK_HOOKS_FILE); const current = readOptional(path); if (current === null) return; if ( @@ -948,7 +1026,7 @@ function createTextJournal( mutated: string, extra: Record ): TextOverlayJournal { - const backupDirectory = join(home, ".canvastty-runtime-backups"); + const backupDirectory = join(home, RUNTIME_BACKUPS); ensurePrivateDirectory(backupDirectory); const backupPath = join(backupDirectory, `${provider}-${randomUUID()}.bak`); if (original !== null) atomicWrite(backupPath, original, modeOf(join(home, provider === "kimi" ? "config.toml" : "config.yaml"))); diff --git a/tests/agent-runtime-account-homes.test.mjs b/tests/agent-runtime-account-homes.test.mjs new file mode 100644 index 00000000..b3061452 --- /dev/null +++ b/tests/agent-runtime-account-homes.test.mjs @@ -0,0 +1,559 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import test from "node:test"; + +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { ProviderRuntimeLaunchAdapters } from "../src/main/services/agent-runtime/ProviderRuntimeLaunch.ts"; +import { ProviderLaunchAdapters } from "../src/main/services/agent-browser/ProviderLaunch.ts"; +import { AgentBrowserBridge } from "../src/main/services/agent-browser/AgentBrowserBridge.ts"; +import { accountHookHome, contributedHookHome, ownedAccountHome } from "../src/main/services/accountHomes.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; + +const helper = Object.freeze({ + command: "/opt/CanvasTTY Agent/electron", + args: ["/opt/CanvasTTY Agent/agent-runtime/hook-helper.mjs"], + env: { ELECTRON_RUN_AS_NODE: "1" } +}); + +const GROK_FILE = ["hooks", "canvastty-runtime-hooks.json"]; +const KIMI_MARKER = "# >>> CanvasTTY lifecycle hooks >>>"; +const PERSON_KIMI = "# the person's own settings\ndefault_model = \"kimi-k2\"\n\n[[hooks]]\nevent = \"Stop\"\ncommand = \"notify-send done\"\n"; + +/** userData with plugin-data//homes/, the default CLI homes, and adapters bound to them. */ +async function fixture(t, { root: withRoot = true, pluginHooks } = {}) { + const base = await mkdtemp(join(tmpdir(), "canvastty-account-homes-")); + t.after(() => rm(base, { recursive: true, force: true })); + const pluginData = join(base, "userData", "plugin-data"); + const accountHome = (plugin, account) => join(pluginData, plugin, "homes", account); + const defaultGrok = join(base, "home", ".grok"); + const defaultKimi = join(base, "home", ".kimi-code"); + await Promise.all([ + mkdir(accountHome("accounts", "a"), { recursive: true }), + mkdir(accountHome("accounts", "b"), { recursive: true }), + mkdir(defaultGrok, { recursive: true }), + mkdir(defaultKimi, { recursive: true }) + ]); + const adapters = () => new ProviderRuntimeLaunchAdapters({ + helper, + runtimeDirectory: join(base, "runtime"), + openCodePluginPath: join(base, "opencode-plugin.mjs"), + grokHomeDirectory: defaultGrok, + kimiHomeDirectory: defaultKimi, + hermesHomeDirectory: join(base, "home", ".hermes"), + ...(withRoot ? { accountHomesRoot: pluginData } : {}), + ...(pluginHooks ? { pluginHooks: { ...pluginHooks, registryPath: join(base, "registry.json") } } : {}), + platform: "linux" + }); + return { base, pluginData, accountHome, defaultGrok, defaultKimi, adapters }; +} + +const exists = (path) => lstat(path).then(() => true, () => false); +const markers = (text) => text.split(KIMI_MARKER).length - 1; +const POSIX = process.platform !== "win32"; + +/** + * A file or folder CanvasTTY made private: on POSIX exactly `mode`; on Windows, where Node reports no POSIX permission + * bits (privacy there comes from the user profile's ACLs), at least a real file or folder of that kind and not a link. + */ +async function assertPrivate(path, mode, message) { + const info = await lstat(path); + assert.equal(info.isSymbolicLink(), false, message); + assert.ok(mode === 0o700 ? info.isDirectory() : info.isFile(), message); + if (POSIX) assert.equal(info.mode & 0o777, mode, message); +} + +test("accountHookHome: GROK_HOME for Grok, KIMI_CODE_HOME for Kimi, nothing for the other CLIs", () => { + assert.equal(accountHookHome("grok", { GROK_HOME: "/data/plugin-data/a/homes/x/" }), "/data/plugin-data/a/homes/x"); + assert.equal(accountHookHome("kimi", { KIMI_CODE_HOME: "/data/plugin-data/a/homes/k" }), "/data/plugin-data/a/homes/k"); + // The older kimi-cli's home holds no Kimi Code hooks. + assert.equal(accountHookHome("kimi", { KIMI_SHARE_DIR: "/data/plugin-data/a/homes/k" }), undefined); + assert.equal(accountHookHome("grok", { KIMI_CODE_HOME: "/x" }), undefined); + assert.equal(accountHookHome("claude", { CLAUDE_CONFIG_DIR: "/x", GROK_HOME: "/y" }), undefined); + assert.equal(accountHookHome("codex", { CODEX_HOME: "/x" }), undefined); + assert.equal(accountHookHome("grok", undefined), undefined); + assert.equal(accountHookHome("grok", { GROK_HOME: "" }), undefined); +}); + +test("contributedHookHome: only the contributor's own data folder, only the hook home variable", () => { + // A native absolute path (a drive path on Windows), as the host's record of the contributor's data folder is. + const dataDir = resolve(sep, "data", "plugin-data", "accounts"); + const homeA = join(dataDir, "homes", "a"); + const homeK = join(dataDir, "homes", "k"); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", `${homeA}${sep}`), homeA); + assert.equal(contributedHookHome("kimi", dataDir, "KIMI_CODE_HOME", homeK), homeK); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", resolve(sep, "data", "plugin-data", "other", "homes", "a")), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", dataDir), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", `${dataDir}${sep}..${sep}other${sep}homes${sep}a`), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", join("homes", "a")), undefined); + assert.equal(contributedHookHome("grok", dataDir, "KIMI_CODE_HOME", homeA), undefined); + assert.equal(contributedHookHome("kimi", dataDir, "KIMI_SHARE_DIR", homeA), undefined); + assert.equal(contributedHookHome("claude", dataDir, "CLAUDE_CONFIG_DIR", homeA), undefined); + assert.equal(contributedHookHome("grok", undefined, "GROK_HOME", homeA), undefined); +}); + +test("a folder whose name only starts with two dots is inside, not a parent: `..cache` is the contributor's own", async (t) => { + const dataDir = resolve(sep, "data", "plugin-data", "accounts"); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", join(dataDir, "..cache")), join(dataDir, "..cache")); + assert.equal(contributedHookHome("kimi", dataDir, "KIMI_CODE_HOME", join(dataDir, "homes", "..k")), join(dataDir, "homes", "..k")); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", `${dataDir}${sep}..`), undefined); + + const { accountHome, defaultGrok, adapters } = await fixture(t); + const home = accountHome("accounts", "..cache"); + await mkdir(home, { recursive: true }); + const launch = adapters().prepare("grok", "card-dots", true, false, undefined, undefined, home); + assert.equal(await exists(join(home, ...GROK_FILE)), true, "the hooks go into the `..cache` account home"); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), false, "not into the default home"); + launch.releaseConfiguration(); + assert.equal(await exists(join(home, ...GROK_FILE)), false); +}); + +test("Grok: an account home gets the status hooks, the default home is left alone, and the release removes them", async (t) => { + const { accountHome, defaultGrok, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + await mkdir(join(home, "hooks"), { recursive: true }); + await writeFile(join(home, "hooks", "person.json"), "{\"hooks\":{}}\n"); + await writeFile(join(home, "config.toml"), "model = \"grok-4\"\n"); + const launch = adapters().prepare("grok", "card-grok", true, false, undefined, undefined, home); + const written = JSON.parse(await readFile(join(home, ...GROK_FILE), "utf8")); + assert.match(written.hooks.UserPromptSubmit[0].hooks[0].command, /working.*UserPromptSubmit/u); + assert.match(written.hooks.Notification[0].hooks[0].command, /needs_approval.*Notification/u); + assert.equal(written.hooks.Notification[0].matcher, "permission_prompt"); + await assertPrivate(join(home, ...GROK_FILE), 0o600); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), false, "the default ~/.grok is not touched"); + launch.releaseConfiguration(); + assert.equal(await exists(join(home, ...GROK_FILE)), false); + assert.equal(await readFile(join(home, "hooks", "person.json"), "utf8"), "{\"hooks\":{}}\n"); + assert.equal(await readFile(join(home, "config.toml"), "utf8"), "model = \"grok-4\"\n"); +}); + +test("Kimi: an account home's config.toml gets one owned block; the person's settings and file mode are kept and restored", async (t) => { + const { accountHome, defaultKimi, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + const config = join(home, "config.toml"); + await writeFile(config, PERSON_KIMI); + await chmod(config, 0o640); + const personMode = (await stat(config)).mode & 0o777; + if (POSIX) assert.equal(personMode, 0o640); + await writeFile(join(defaultKimi, "config.toml"), "default_model = \"default\"\n"); + const launch = adapters().prepare("kimi", "card-kimi", true, false, undefined, undefined, home); + const during = await readFile(config, "utf8"); + assert.ok(during.startsWith(PERSON_KIMI), "the person's settings stay first and unchanged"); + assert.equal(markers(during), 1); + assert.match(during, /event = "TurnStarted"\ncommand = ".*working.*TurnStarted/u); + assert.equal((await stat(config)).mode & 0o777, personMode, "the person's file mode is kept"); + assert.equal(await readFile(join(defaultKimi, "config.toml"), "utf8"), "default_model = \"default\"\n"); + assert.equal(await exists(join(defaultKimi, ".canvastty-runtime-kimi.json")), false); + launch.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + assert.equal(await exists(join(home, ".canvastty-runtime-kimi.json")), false); + assert.deepEqual(await readdir(join(home, ".canvastty-runtime-backups")).catch(() => []), [], "no backup is left"); +}); + +test("idempotent: two cards in one account home share one overlay; another home gets its own; a crashed overlay is refreshed", async (t) => { + const { accountHome, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + const other = accountHome("accounts", "b"); + const config = join(home, "config.toml"); + await writeFile(config, PERSON_KIMI); + const runtime = adapters(); + const first = runtime.prepare("kimi", "card-1", true, false, undefined, undefined, home); + const second = runtime.prepare("kimi", "card-2", true, false, undefined, undefined, `${home}/`); + const third = runtime.prepare("kimi", "card-3", true, false, undefined, undefined, other); + assert.equal(markers(await readFile(config, "utf8")), 1); + assert.equal(markers(await readFile(join(other, "config.toml"), "utf8")), 1); + first.releaseConfiguration(); + first.releaseConfiguration(); + assert.equal(markers(await readFile(config, "utf8")), 1, "still in use by card-2"); + second.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + third.releaseConfiguration(); + assert.equal(await exists(join(other, "config.toml")), false, "a file CanvasTTY created is removed again"); + + // An app that ended without releasing (a crash) leaves its overlay; the next launch there replaces it, not adds one. + runtime.prepare("kimi", "card-crashed", true, false, undefined, undefined, home); + runtime.prepare("grok", "card-crashed-grok", true, false, undefined, undefined, home); + const restarted = adapters(); + const again = restarted.prepare("kimi", "card-again", true, false, undefined, undefined, home); + const grokAgain = restarted.prepare("grok", "card-again-grok", true, false, undefined, undefined, home); + assert.equal(markers(await readFile(config, "utf8")), 1); + assert.ok((await readFile(config, "utf8")).startsWith(PERSON_KIMI)); + again.releaseConfiguration(); + grokAgain.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + assert.equal(await exists(join(home, ...GROK_FILE)), false); +}); + +test("a changed hook set refreshes an account home's overlay but keeps the earlier launches' leases", async (t) => { + let registrations = []; + const { accountHome, adapters } = await fixture(t, { + pluginHooks: { runner: { command: process.execPath, args: [join(tmpdir(), "runner.mjs")], env: {} }, list: () => registrations } + }); + const runtime = adapters(); + for (const provider of ["kimi", "grok"]) { + registrations = []; + const home = accountHome("accounts", provider === "kimi" ? "a" : "b"); + const written = provider === "kimi" ? join(home, "config.toml") : join(home, ...GROK_FILE); + const older = runtime.prepare(provider, `card-old-${provider}`, true, false, undefined, undefined, home); + // A plugin hook is registered while the older card still runs: the next card's overlay carries it. + registrations = [{ key: "example:hook", events: ["prompt-submit"] }]; + const newer = runtime.prepare(provider, `card-new-${provider}`, true, false, undefined, undefined, home); + assert.match(await readFile(written, "utf8"), /example:hook|registry\.json/u, `${provider}: the refreshed overlay has the plugin hook`); + if (provider === "kimi") assert.equal(markers(await readFile(written, "utf8")), 1); + newer.releaseConfiguration(); + assert.equal(await exists(written), true, `${provider}: the older card still holds the home, so its overlay stays`); + if (provider === "kimi") assert.equal(markers(await readFile(written, "utf8")), 1); + older.releaseConfiguration(); + assert.equal(await exists(written), false, `${provider}: removed with the last launch`); + older.releaseConfiguration(); + } +}); + +test("a default home configured with a trailing separator is still the default home, not an account home", async (t) => { + const { base, pluginData, accountHome } = await fixture(t); + const defaultKimi = accountHome("accounts", "default"); + await mkdir(defaultKimi, { recursive: true }); + assert.equal(ownedAccountHome({ root: pluginData, candidate: defaultKimi, defaultHome: `${defaultKimi}${sep}`, written: [], label: "Kimi" }), null); + assert.equal(ownedAccountHome({ root: pluginData, candidate: `${defaultKimi}${sep}`, defaultHome: defaultKimi, written: [], label: "Kimi" }), null); + + const runtime = new ProviderRuntimeLaunchAdapters({ + helper, + runtimeDirectory: join(base, "runtime"), + openCodePluginPath: join(base, "opencode-plugin.mjs"), + grokHomeDirectory: join(base, "home", ".grok"), + kimiHomeDirectory: `${defaultKimi}${sep}`, + hermesHomeDirectory: join(base, "home", ".hermes"), + accountHomesRoot: pluginData, + platform: "linux" + }); + const config = join(defaultKimi, "config.toml"); + await writeFile(config, PERSON_KIMI); + const defaultCard = runtime.prepare("kimi", "card-default"); + const contributed = runtime.prepare("kimi", "card-contributed", true, false, undefined, undefined, defaultKimi); + assert.equal(markers(await readFile(config, "utf8")), 1); + contributed.releaseConfiguration(); + assert.equal(markers(await readFile(config, "utf8")), 1, "the default card still runs: its hooks stay"); + defaultCard.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + + const browser = new ProviderLaunchAdapters({ + helper: browserHelper, + orchestrationHelper, + providerClis: registry, + runtimeDirectory: join(base, "browser-runtime"), + hermesHomeDirectory: join(base, "home", ".hermes"), + kimiHomeDirectory: `${defaultKimi}${sep}`, + probeKimiPerRunConfig: () => false, + accountHomesRoot: pluginData + }); + const mcp = join(defaultKimi, "mcp.json"); + await writeFile(mcp, PERSON_MCP); + const defaultTools = browser.prepare("kimi", "connection-default", { orchestration: true }); + const contributedTools = browser.prepare("kimi", "connection-contributed", { orchestration: true, accountHome: defaultKimi }); + contributedTools.releaseConfiguration(); + assert.ok(JSON.parse(await readFile(mcp, "utf8")).mcpServers.canvastty_browser, "the default card's tools stay"); + defaultTools.releaseConfiguration(); + assert.equal(await readFile(mcp, "utf8"), PERSON_MCP); +}); + +test("symbolic links: a linked account home, a linked folder above it, or a linked config file is refused and nothing is written", async (t) => { + const { base, pluginData, accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const outside = join(base, "outside"); + await mkdir(join(outside, "hooks"), { recursive: true }); + await writeFile(join(outside, "config.toml"), "outside = true\n"); + const runtime = adapters(); + + const linkedHome = accountHome("accounts", "linked"); + await symlink(outside, linkedHome); + for (const provider of ["grok", "kimi"]) { + assert.throws(() => runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, linkedHome), + /missing or crosses a symbolic link/u); + } + + await symlink(join(pluginData, "accounts"), join(pluginData, "alias")); + assert.throws(() => runtime.prepare("grok", "card-alias", true, false, undefined, undefined, join(pluginData, "alias", "homes", "a")), + /missing or crosses a symbolic link/u); + + const kimiHome = accountHome("accounts", "a"); + await symlink(join(outside, "config.toml"), join(kimiHome, "config.toml")); + assert.throws(() => runtime.prepare("kimi", "card-config", true, false, undefined, undefined, kimiHome), + /symbolic link where CanvasTTY writes/u); + + const grokHome = accountHome("accounts", "b"); + await symlink(join(outside, "hooks"), join(grokHome, "hooks")); + assert.throws(() => runtime.prepare("grok", "card-hooks", true, false, undefined, undefined, grokHome), + /symbolic link where CanvasTTY writes/u); + + assert.deepEqual((await readdir(outside)).sort(), ["config.toml", "hooks"]); + assert.equal(await readFile(join(outside, "config.toml"), "utf8"), "outside = true\n"); + assert.deepEqual(await readdir(join(outside, "hooks")), []); + assert.deepEqual(await readdir(defaultGrok), []); + assert.deepEqual(await readdir(defaultKimi), []); + assert.equal(await exists(join(kimiHome, ".canvastty-runtime-kimi.json")), false); +}); + +test("a new account's home that does not exist yet is created (private, no link followed) and gets the overlays", async (t) => { + const { accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const runtime = adapters(); + for (const provider of ["grok", "kimi"]) { + // A first sign-in: the contributor names the account's home before the CLI ever created it. + const home = accountHome("accounts", `new-${provider}`); + assert.equal(await exists(home), false); + const launch = runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, home); + await assertPrivate(home, 0o700, `${provider}: created private`); + if (provider === "grok") assert.equal(await exists(join(home, ...GROK_FILE)), true); + else assert.match(await readFile(join(home, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + launch.releaseConfiguration(); + } + assert.deepEqual(await readdir(defaultGrok), []); + assert.deepEqual(await readdir(defaultKimi), []); +}); + +test("homes the launch does not own are never written: outside plugin data, a whole plugin's folder, relative, or no root", async (t) => { + const { base, pluginData, defaultGrok, defaultKimi, adapters } = await fixture(t); + const elsewhere = join(base, "elsewhere"); + await mkdir(elsewhere, { recursive: true }); + const runtime = adapters(); + for (const candidate of [elsewhere, join(pluginData, "accounts"), pluginData, "relative/home", `${pluginData}/accounts/../accounts/homes/a`]) { + const launch = runtime.prepare("grok", "card", true, false, undefined, undefined, candidate); + const kimi = runtime.prepare("kimi", "card-k", true, false, undefined, undefined, candidate); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), true, `${candidate}: the default home, as before`); + assert.match(await readFile(join(defaultKimi, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + launch.releaseConfiguration(); + kimi.releaseConfiguration(); + } + assert.deepEqual(await readdir(elsewhere), []); + assert.deepEqual(await readdir(join(pluginData, "accounts", "homes", "a")), []); + + const noRoot = await fixture(t, { root: false }); + const home = noRoot.accountHome("accounts", "a"); + const launch = noRoot.adapters().prepare("grok", "card", true, false, undefined, undefined, home); + assert.deepEqual(await readdir(home), []); + assert.equal(await exists(join(noRoot.defaultGrok, ...GROK_FILE)), true); + launch.releaseConfiguration(); +}); + +test("default homes are unchanged without an account home, and hooks off writes nowhere", async (t) => { + const { accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const runtime = adapters(); + const grok = runtime.prepare("grok", "card-grok"); + const kimi = runtime.prepare("kimi", "card-kimi"); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), true); + assert.equal(markers(await readFile(join(defaultKimi, "config.toml"), "utf8")), 1); + grok.releaseConfiguration(); + kimi.releaseConfiguration(); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), false); + assert.equal(await exists(join(defaultKimi, "config.toml")), false); + + const home = accountHome("accounts", "a"); + const off = runtime.prepare("grok", "card-off", false, false, undefined, undefined, home); + assert.deepEqual(await readdir(home), []); + off.releaseConfiguration(); + // Claude Code and Codex take their hooks as arguments; their homes are never written either way. + for (const provider of ["claude", "codex"]) { + const launch = runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, home); + assert.ok(launch.args.length > 0); + launch.releaseConfiguration(); + } + assert.deepEqual(await readdir(home), []); +}); + +const PERSON_MCP = `${JSON.stringify({ mcpServers: { person: { transport: "stdio", command: "/usr/bin/true" } } }, null, 2)}\n`; +const BROWSER_RULE = "# CanvasTTY temporary browser permission begin"; + +const browserHelper = Object.freeze({ command: "/opt/CanvasTTY Agent/electron", args: ["/opt/CanvasTTY Agent/agent-browser/helper.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); +const orchestrationHelper = Object.freeze({ command: "/opt/CanvasTTY Agent/electron", args: ["/opt/CanvasTTY Agent/agent-browser/orchestration.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); + +const registry = Object.freeze({ + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) +}); + +function browserAdapters(fix, perRun, withRoot = true) { + return new ProviderLaunchAdapters({ + helper: browserHelper, + orchestrationHelper, + providerClis: registry, + runtimeDirectory: join(fix.base, "browser-runtime"), + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + kimiHomeDirectory: fix.defaultKimi, + probeKimiPerRunConfig: () => perRun, + ...(withRoot ? { accountHomesRoot: fix.pluginData } : {}) + }); +} + +test("Kimi tools (per-run MCP file): the account home gets the permission rule, the default home nothing; restored after", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "config.toml"), PERSON_KIMI); + await writeFile(join(fix.defaultKimi, "config.toml"), "default_model = \"default\"\n"); + const adapters = browserAdapters(fix, true); + const launch = adapters.prepare("kimi", "connection-1", { orchestration: true, accountHome: home }); + const mcpFile = launch.args[launch.args.indexOf("--mcp-config-file") + 1]; + const servers = JSON.parse(await readFile(mcpFile, "utf8")).mcpServers; + assert.deepEqual(Object.keys(servers).sort(), ["canvastty_agents", "canvastty_browser"]); + const during = await readFile(join(home, "config.toml"), "utf8"); + assert.ok(during.startsWith(PERSON_KIMI)); + assert.equal(during.split(BROWSER_RULE).length - 1, 1); + assert.equal(await readFile(join(fix.defaultKimi, "config.toml"), "utf8"), "default_model = \"default\"\n"); + // A second card in the same home shares the rule; another home gets its own. + const second = adapters.prepare("kimi", "connection-2", { orchestration: true, accountHome: `${home}/` }); + assert.equal((await readFile(join(home, "config.toml"), "utf8")).split(BROWSER_RULE).length - 1, 1); + launch.releaseConfiguration(); + assert.equal((await readFile(join(home, "config.toml"), "utf8")).split(BROWSER_RULE).length - 1, 1); + second.releaseConfiguration(); + assert.equal(await readFile(join(home, "config.toml"), "utf8"), PERSON_KIMI); + assert.deepEqual((await readdir(home)).sort(), ["config.toml"]); +}); + +test("Kimi tools (shared mcp.json): the account home's mcp.json gets both servers next to the person's; restored after", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "mcp.json"), PERSON_MCP); + const launch = browserAdapters(fix, false).prepare("kimi", "connection-1", { orchestration: true, accountHome: home }); + assert.equal(launch.args.includes("--mcp-config-file"), false); + const servers = JSON.parse(await readFile(join(home, "mcp.json"), "utf8")).mcpServers; + assert.deepEqual(Object.keys(servers).sort(), ["canvastty_agents", "canvastty_browser", "person"]); + assert.match(await readFile(join(home, "config.toml"), "utf8"), /CanvasTTY temporary browser permission begin/u); + assert.deepEqual(await readdir(fix.defaultKimi), [], "the default home is not touched"); + launch.releaseConfiguration(); + assert.equal(await readFile(join(home, "mcp.json"), "utf8"), PERSON_MCP); + assert.deepEqual((await readdir(home)).sort(), ["mcp.json"]); +}); + +test("Kimi tools: symbolic links are refused, and a home the launch does not own falls back to the default home", async (t) => { + const fix = await fixture(t); + const outside = join(fix.base, "outside"); + await mkdir(outside, { recursive: true }); + await writeFile(join(outside, "mcp.json"), PERSON_MCP); + const home = fix.accountHome("accounts", "a"); + await symlink(join(outside, "mcp.json"), join(home, "mcp.json")); + const adapters = browserAdapters(fix, false); + assert.throws(() => adapters.prepare("kimi", "c", { accountHome: home }), /symbolic link where CanvasTTY writes/u); + const linked = fix.accountHome("accounts", "linked"); + await symlink(outside, linked); + assert.throws(() => adapters.prepare("kimi", "c", { accountHome: linked }), /missing or crosses a symbolic link/u); + assert.equal(await readFile(join(outside, "mcp.json"), "utf8"), PERSON_MCP); + assert.deepEqual(await readdir(outside), ["mcp.json"]); + + const elsewhere = join(fix.base, "elsewhere"); + await mkdir(elsewhere, { recursive: true }); + for (const [candidate, runtime] of [[elsewhere, adapters], [fix.accountHome("accounts", "b"), browserAdapters(fix, false, false)]]) { + const launch = runtime.prepare("kimi", "c", { accountHome: candidate }); + assert.ok(JSON.parse(await readFile(join(fix.defaultKimi, "mcp.json"), "utf8")).mcpServers.canvastty_browser); + launch.releaseConfiguration(); + assert.deepEqual(await readdir(candidate), []); + } +}); + +/** A card a launch contributor (data folder `plugin-data/`) starts with `variable` = `home`, through TerminalManager. */ +async function contributedCard(t, fix, { provider, variable, home, owner = "accounts" }) { + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: owner, pluginName: "Accounts", serviceId: "launcher", + launch: { appliesTo: [provider], fields: [{ key: "on", label: "On", kind: "boolean", default: true }] }, secrets: false, + dataDir: join(fix.pluginData, owner) }], + call: async () => ({ env: { [variable]: home } }), + secret: async () => null, + runsRoot: join(fix.base, "runs"), + timeoutMs: 1_000 + }); + const gateway = { + registerSession: (terminalSessionId, kind) => ({ address: join(fix.base, "rt.sock"), terminalSessionId, provider: kind, capabilityToken: "t" }), + revokeTerminalSession() {}, + currentStatus: () => null + }; + const bridge = new AgentRuntimeBridge(gateway, { + helper, + runtimeDirectory: join(fix.base, "runtime"), + openCodePluginPath: join(fix.base, "opencode-plugin.mjs"), + grokHomeDirectory: fix.defaultGrok, + kimiHomeDirectory: fix.defaultKimi, + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + accountHomesRoot: fix.pluginData, + platform: "linux" + }); + const browserGateway = { + isEnabled: true, + registerAgent: (input) => ({ agentId: "agent", connectionId: `connection-${input.terminalSessionId}`, terminalSessionId: input.terminalSessionId, + provider: input.provider, capabilityToken: "c", address: join(fix.base, "browser.sock"), authenticated: new Promise(() => {}) }), + revokeTerminalSession() {}, + holdPendingForTerminal() {} + }; + const browser = new AgentBrowserBridge(browserGateway, { + helper: browserHelper, + providerClis: registry, + runtimeDirectory: join(fix.base, "browser-runtime"), + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + kimiHomeDirectory: fix.defaultKimi, + probeKimiPerRunConfig: () => false, + accountHomesRoot: fix.pluginData + }); + const calls = []; + const spawner = (command, args, options) => { + const exits = []; + calls.push({ command, args, options, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }); + return { + pid: 41_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; + const manager = new TerminalManager(() => undefined, registry, browser, bridge, true, spawner); + t.after(() => manager.shutdown()); + manager.configureLaunchPipeline(pipeline); + const created = manager.create({ provider, profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 }, launchOptions: { [owner]: {} } }); + // Grok starts once the card reports its measured grid. + manager.resize(created.id, 100, 30); + const deadline = Date.now() + 5_000; + while (calls.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(calls.length, 1, JSON.stringify(manager.list().map((card) => [card.status, card.failureDetails]))); + assert.equal(calls[0].options.env[variable], home); + return { exit: () => calls[0].exit(0) }; +} + +async function until(predicate) { + const deadline = Date.now() + 5_000; + while (!(await predicate()) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(await predicate()); +} + +test("a Grok card a contributor starts with GROK_HOME in its own account home gets the hooks there, through TerminalManager", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + const card = await contributedCard(t, fix, { provider: "grok", variable: "GROK_HOME", home }); + assert.equal(await exists(join(home, ...GROK_FILE)), true); + assert.equal(await exists(join(fix.defaultGrok, ...GROK_FILE)), false); + card.exit(); + await until(async () => !(await exists(join(home, ...GROK_FILE)))); +}); + +test("a Kimi card in its own account home gets status hooks and its browser/agents tools there, through TerminalManager", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "config.toml"), PERSON_KIMI); + await writeFile(join(home, "mcp.json"), PERSON_MCP); + const card = await contributedCard(t, fix, { provider: "kimi", variable: "KIMI_CODE_HOME", home }); + const config = await readFile(join(home, "config.toml"), "utf8"); + assert.ok(config.startsWith(PERSON_KIMI)); + assert.equal(markers(config), 1); + assert.equal(config.split(BROWSER_RULE).length - 1, 1); + assert.ok(JSON.parse(await readFile(join(home, "mcp.json"), "utf8")).mcpServers.canvastty_browser); + assert.deepEqual(await readdir(fix.defaultKimi), []); + card.exit(); + await until(async () => (await readFile(join(home, "config.toml"), "utf8")) === PERSON_KIMI); + await until(async () => (await readFile(join(home, "mcp.json"), "utf8")) === PERSON_MCP); +}); + +test("a home in another plugin's data is not the launch's account home: nothing is written there, the default home is used", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + for (const [provider, variable] of [["grok", "GROK_HOME"], ["kimi", "KIMI_CODE_HOME"]]) { + const card = await contributedCard(t, fix, { provider, variable, home, owner: "other" }); + assert.deepEqual(await readdir(home), [], provider); + if (provider === "grok") assert.equal(await exists(join(fix.defaultGrok, ...GROK_FILE)), true); + else assert.match(await readFile(join(fix.defaultKimi, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + card.exit(); + } +}); diff --git a/tests/decision-hooks.test.mjs b/tests/decision-hooks.test.mjs index b7a4a230..2afb7fda 100644 --- a/tests/decision-hooks.test.mjs +++ b/tests/decision-hooks.test.mjs @@ -345,7 +345,7 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode const sent = []; const decisions = createOpenCodeDecisions({ client, env, send: async (_address, message) => { sent.push(message); return next; } }); await assert.rejects(decisions.guard({ tool: "write", callID: "c1" }, { args: { filePath: "/etc/hosts", content: "x" } }), /Not outside the project/u); - assert.equal(sent[0].toolName, "edit"); + assert.equal(sent[0].toolName, "Write", "a whole-file write keeps its own name"); next = { behavior: "allow", message: "" }; await decisions.guard({ tool: "bash", callID: "c2" }, { args: { command: "ls" } }); assert.equal(await decisions.permissionAsked({ id: "r1", tool: { callID: "c2" } }), true); @@ -358,9 +358,27 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode assert.equal(sent.length, 3, "tools that neither run commands nor write files are not sent"); assert.equal(createOpenCodeDecisions({ env: { ...env, [OPENCODE_DECISIONS_ENV]: "0" } }).enabled, false); assert.deepEqual(guardedCall("apply_patch", { patchText: "*** Begin Patch" }), { toolName: "apply_patch", toolInput: { patch: "*** Begin Patch" } }); - // multiedit: every edit's new text reaches the decision, as Claude's MultiEdit tool_input does. - assert.deepEqual(guardedCall("multiedit", { filePath: "/p/a.ts", edits: [{ oldString: "a", newString: "TOKEN=1" }, { oldString: "b", newString: "rm -rf /" }, { newString: 5 }] }), - { toolName: "edit", toolInput: { file_path: "/p/a.ts", content: "TOKEN=1\nrm -rf /" } }); + // OpenCode's file tools go out in Claude Code's shapes: Write is the whole new file; Edit and MultiEdit carry the + // text they replace as well as the new text (OpenCode run 15: edit {filePath, oldString, newString, replaceAll}). + assert.deepEqual(guardedCall("write", { filePath: "/p/new.mjs", content: "export const a = 1;\n" }), { toolName: "Write", toolInput: { file_path: "/p/new.mjs", content: "export const a = 1;\n" } }); + assert.deepEqual(guardedCall("edit", { filePath: "/p/a.ts", oldString: "const a = 1;", newString: "const a = 2;", replaceAll: false }), + { toolName: "Edit", toolInput: { file_path: "/p/a.ts", old_string: "const a = 1;", new_string: "const a = 2;", replace_all: false } }); + assert.deepEqual(guardedCall("edit", { filePath: "/p/a.ts", oldString: "x", newString: "" }), + { toolName: "Edit", toolInput: { file_path: "/p/a.ts", old_string: "x", new_string: "" } }, "a deletion keeps its empty new text; replaceAll absent stays absent"); + assert.deepEqual(guardedCall("multiedit", { filePath: "/p/a.ts", edits: [{ filePath: "/p/a.ts", oldString: "a", newString: "TOKEN=1" }, + { oldString: "b", newString: "rm -rf /", replaceAll: true }, { newString: 5 }, null] }), + { toolName: "MultiEdit", toolInput: { file_path: "/p/a.ts", edits: [{ old_string: "a", new_string: "TOKEN=1" }, + { old_string: "b", new_string: "rm -rf /", replace_all: true }] } }); + assert.equal(guardedCall("edit", { oldString: "a", newString: "b" }), null, "no path, nothing to check"); + // Through the host: base protection still finds the path, and the service gets the edit's kind, path and both texts. + const calls = []; + const host = hooks({ services: [service("p")], answers: { p: null }, calls }); + const edit = guardedCall("edit", { filePath: join(project, "a.ts"), oldString: "a", newString: "b" }); + assert.equal((await host.decide("s1", request(edit.toolName, edit.toolInput), live())).behavior, "none"); + assert.deepEqual(calls[0].params.tool, { name: "Edit", kind: "edit", command: null, paths: [join(project, "a.ts")] }); + assert.deepEqual(calls[0].params.input, { file_path: join(project, "a.ts"), old_string: "a", new_string: "b" }); + const outside = guardedCall("multiedit", { filePath: "/etc/hosts", edits: [{ oldString: "a", newString: "b" }] }); + assert.equal((await host.decide("s1", request(outside.toolName, outside.toolInput), live())).behavior, "deny", "MultiEdit outside the project"); }); test("manifest: decide needs decision:provide, lists pre-tool, and at most one service per plugin decides", () => {