From b02365e59d6305a949898688be4a54da9a4ebbee Mon Sep 17 00:00:00 2001 From: Jiahe Geng <146067293+m0Nst3r873@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:12:48 +0800 Subject: [PATCH] feat(http-source): push one-shot commands (type: "cmd") via sync + ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `type: "cmd"` one-shot command to the HTTP source sync channel so the backend can push a single teamai subcommand (e.g. `teamai uninstall --agent claude`) to run once on the client, with the result reported via the existing ack channel. Delivery, ack, and retry semantics are reused unchanged. Security boundary: - teamai subcommands only — argv[0] must strictly equal `teamai`; anything else is rejected (acked failed) and never executed. - No shell — execFile(process.execPath, [entry, ...args]); shell metacharacters are literal, no PATH dependency (works in bundled-node sandboxes). - 120s timeout, 4 MiB maxBuffer, error detail capped at 200 chars. - TEAMAI_DISABLE_REMOTE_CMD=1 kill switch (acked failed). Also change the CloudStudio sandbox guard from "skip report + sync" to "skip report, still run sync + processCommands", so sandboxed agents can receive pushed commands. Report-side bookkeeping (plugin reconcile, binding prune, config save) stays gated behind the report path to avoid pruning host bindings not mounted in the container. Docs updated in both languages; unit tests cover the tokenizer, rejection, kill switch, subcommand failure, and the sandbox no-prune guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/usage-guide.md | 21 ++- docs/usage-guide.zh-CN.md | 18 ++- src/__tests__/local-agent.test.ts | 214 ++++++++++++++++++++++++++++-- src/builtin-hooks.ts | 2 +- src/local-agent.ts | 186 +++++++++++++++++++++++--- 5 files changed, 400 insertions(+), 41 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f07a5f62..df1ad55d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -582,6 +582,19 @@ When using `teamai init --http `, the endpoint must implement the follo } ``` +The backend may also push a **one-shot command** (`type: "cmd"`) to run once on the client — e.g. to trigger an uninstall — with the result reported back through the same ack channel: + +```json +{ "id": 42, "type": "cmd", "cmd": "teamai uninstall --agent claude" } +``` + +Security boundary for `cmd`: + +- **teamai subcommands only** — the first token must be exactly `teamai`; anything else is rejected (acked `failed`) and never executed. There is no arbitrary-shell surface. +- **No shell** — the command is run via `execFile` with the current Node binary and teamai entry script, so shell metacharacters (`;`, `|`, `&`, `$`, …) are treated as literals and there is no PATH dependency (works inside sandboxes with a bundled Node). +- **On by default** — like install/uninstall commands, `cmd` runs automatically. Set `TEAMAI_DISABLE_REMOTE_CMD=1` on the client to reject it (acked `failed` with `remote cmd disabled by client`). +- **Timeout** — a hung command is killed after 120s and acked `failed`. + Configurable environment variables: | Variable | Purpose | @@ -592,13 +605,15 @@ Configurable environment variables: | `TEAMAI_REPORT_AGENTS` | Comma-separated list of agents that report (default `workbuddy,codebuddy`) | | `TEAMAI_SKILL_DOWNLOAD_HOSTS` | Allowlist of hosts for skill `download_url` (empty = allow all) | | `TEAMAI_ALLOW_SANDBOX_REPORT` | Set to `1` to force report/sync inside a CloudStudio sandbox (see note below) | +| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed one-shot `cmd` commands (they are acked `failed`) | > **Privacy:** The install path and machine id are only hashed locally to derive `local_agent_id` — they are never reported. > **CloudStudio sandbox:** When WorkBuddy runs teamai hooks inside a CloudStudio container, that container has a -> different machine id than the macOS host and would report a duplicate agent card. Report/sync is therefore skipped -> automatically inside a CloudStudio sandbox (detected via `X_IDE_IS_CLOUDSTUDIO=TRUE` or the `/var/run/cloudstudio` -> directory). Set `TEAMAI_ALLOW_SANDBOX_REPORT=1` to opt back in if you run teamai exclusively inside CloudStudio. +> different machine id than the macOS host and would report a duplicate agent card. The duplicate report is therefore +> skipped automatically inside a CloudStudio sandbox (sync still runs, so pushed commands are still received) — +> detected via `X_IDE_IS_CLOUDSTUDIO=TRUE` or the `/var/run/cloudstudio` directory. +> Set `TEAMAI_ALLOW_SANDBOX_REPORT=1` to opt back in if you run teamai exclusively inside CloudStudio. ### Codebase Knowledge Graph diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 27046ee4..53f0f182 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -580,6 +580,19 @@ cat ~/.claude/CLAUDE.md } ``` +后端也可下发**一次性命令**(`type: "cmd"`),让客户端执行一次 —— 例如触发卸载 —— 执行结果经同一 ack 通道回报: + +```json +{ "id": 42, "type": "cmd", "cmd": "teamai uninstall --agent claude" } +``` + +`cmd` 的安全边界: + +- **仅限 teamai 子命令** —— 第一个 token 必须严格等于 `teamai`;其它一律拒绝(ack `failed`)且不执行,不存在任意 shell 面。 +- **无 shell** —— 命令经 `execFile` 用当前 Node 二进制与 teamai 入口脚本运行,shell 元字符(`;`、`|`、`&`、`$` 等)按字面处理,且不依赖 PATH(沙箱内自带 Node 也可运行)。 +- **默认开启** —— 与 install/uninstall 命令一致,`cmd` 会自动执行。客户端设 `TEAMAI_DISABLE_REMOTE_CMD=1` 可拒绝(ack `failed`,错误为 `remote cmd disabled by client`)。 +- **超时** —— 命令卡住 120s 后被杀掉并 ack `failed`。 + 可配置环境变量: | 变量 | 作用 | @@ -590,12 +603,13 @@ cat ~/.claude/CLAUDE.md | `TEAMAI_REPORT_AGENTS` | 参与上报的 agent,逗号分隔(默认 `workbuddy,codebuddy`) | | `TEAMAI_SKILL_DOWNLOAD_HOSTS` | skill `download_url` host 白名单(空 = 全部放行) | | `TEAMAI_ALLOW_SANDBOX_REPORT` | 设为 `1` 可强制在 CloudStudio 沙箱内 report/sync(见下方说明) | +| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的一次性 `cmd` 命令(会 ack `failed`) | > **隐私**:install path 和 machine id 仅在本地哈希以派生 `local_agent_id`,不会上报。 > **CloudStudio 沙箱**:当 WorkBuddy 在 CloudStudio 容器内运行 teamai hook 时,该容器的 machine id 与 macOS -> 宿主不同,会上报一张重复的 agent 卡片。因此在 CloudStudio 沙箱内会自动跳过 report/sync(通过 -> `X_IDE_IS_CLOUDSTUDIO=TRUE` 或 `/var/run/cloudstudio` 目录检测)。若你只在 CloudStudio 内使用 teamai,可设 +> 宿主不同,会上报一张重复的 agent 卡片。因此在 CloudStudio 沙箱内会自动跳过重复的 report(sync 仍会执行,因此仍能收到下发命令)—— +> 通过 `X_IDE_IS_CLOUDSTUDIO=TRUE` 或 `/var/run/cloudstudio` 目录检测。若你只在 CloudStudio 内使用 teamai,可设 > `TEAMAI_ALLOW_SANDBOX_REPORT=1` 重新开启上报。 ### 代码知识图谱 diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index f7193829..7b863992 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -938,18 +938,22 @@ describe('local-agent: CloudStudio sandbox suppression', () => { delete process.env.TEAMAI_ALLOW_SANDBOX_REPORT; }); - it('returns false without fetching when X_IDE_IS_CLOUDSTUDIO=TRUE', async () => { + it('skips report but still runs sync when X_IDE_IS_CLOUDSTUDIO=TRUE', async () => { process.env.X_IDE_IS_CLOUDSTUDIO = 'TRUE'; await setupConfig(); - - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }))); + let reportCalled = false; + let syncCalled = false; + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/report')) reportCalled = true; + if (url.includes('/sync')) syncCalled = true; + return new Response(JSON.stringify({ ok: true })); + }); vi.stubGlobal('fetch', fetchMock); - const { reportAndSyncLocalAgent } = await import('../local-agent.js'); const result = await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); - - expect(result).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); + expect(reportCalled).toBe(false); + expect(syncCalled).toBe(true); + expect(result).toBe(true); }); it('proceeds with normal reporting when TEAMAI_ALLOW_SANDBOX_REPORT=1 overrides sandbox', async () => { @@ -979,18 +983,50 @@ describe('local-agent: CloudStudio sandbox suppression', () => { expect(fetchMock).toHaveBeenCalled(); }); - it('returns false without fetching when /var/run/cloudstudio exists', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); + it('skips report but still runs sync when /var/run/cloudstudio exists', async () => { + vi.spyOn(fs, 'existsSync').mockImplementation((p) => String(p).includes('cloudstudio')); await setupConfig(); + let reportCalled = false; + let syncCalled = false; + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/report')) reportCalled = true; + if (url.includes('/sync')) syncCalled = true; + return new Response(JSON.stringify({ ok: true })); + }); + vi.stubGlobal('fetch', fetchMock); + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + const result = await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); + expect(reportCalled).toBe(false); + expect(syncCalled).toBe(true); + expect(result).toBe(true); + }); - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }))); + it('does not prune workspace bindings or rewrite config inside the sandbox', async () => { + process.env.X_IDE_IS_CLOUDSTUDIO = 'TRUE'; + // A binding whose workspace path does not exist on disk — outside the sandbox + // this would be pruned and the config rewritten. Inside the sandbox it must survive. + const deadPath = path.join(tmpDir, 'not-mounted-in-container'); + await setupConfig({ [deadPath]: { projectId: 7, projectName: 'dead-ws', boundAt: '2026-01-01T00:00:00.000Z' } }); + const configPath = path.join(tmpDir, '.teamai', 'local-agent', 'config.json'); + const before = await fse.readJson(configPath); + + let syncCalled = false; + const fetchMock = vi.fn(async (url: string) => { + if (url.includes('/sync')) syncCalled = true; + return new Response(JSON.stringify({ ok: true })); + }); vi.stubGlobal('fetch', fetchMock); const { reportAndSyncLocalAgent } = await import('../local-agent.js'); const result = await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); - expect(result).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); + const after = await fse.readJson(configPath); + // Binding preserved + config untouched (no prune/save ran). + expect(after.workspaceBindings).toEqual(before.workspaceBindings); + expect(after.workspaceBindings[deadPath]).toBeTruthy(); + // Sync still runs so pushed cmds are delivered. + expect(syncCalled).toBe(true); + expect(result).toBe(true); }); it('still emits binding hint (stdout) but skips HTTP report inside sandbox', async () => { @@ -1038,8 +1074,158 @@ describe('local-agent: CloudStudio sandbox suppression', () => { // Binding hint must still be injected via stdout even inside the sandbox. expect(output).toContain('hookSpecificOutput'); expect(output).toContain('绑定到「alpha」项目'); - // But the HTTP report/sync must be skipped, and the function returns false. + // But the HTTP report must be skipped; sync still runs, function returns true. expect(reportCalled).toBe(false); - expect(result).toBe(false); + expect(result).toBe(true); + }); +}); + +describe('local-agent: parseTeamaiCmd tokenizer', () => { + it('splits quoted args and keeps spaces inside quotes', async () => { + const { parseTeamaiCmd } = await import('../local-agent.js'); + expect(parseTeamaiCmd('teamai foo --name "a b"')).toEqual(['teamai', 'foo', '--name', 'a b']); + expect(parseTeamaiCmd("teamai uninstall --agent 'claude code'")).toEqual( + ['teamai', 'uninstall', '--agent', 'claude code'], + ); + }); + + it('rejects a non-teamai first token', async () => { + const { parseTeamaiCmd } = await import('../local-agent.js'); + expect(() => parseTeamaiCmd('rm -rf /')).toThrow(/only "teamai"/); + }); + + it('rejects empty input and unterminated quotes', async () => { + const { parseTeamaiCmd } = await import('../local-agent.js'); + expect(() => parseTeamaiCmd(' ')).toThrow(/Empty cmd/); + expect(() => parseTeamaiCmd('teamai "oops')).toThrow(/Unterminated quote/); + }); +}); + +describe('local-agent: type=cmd command execution', () => { + let origArgv1: string; + let helperScript: string; + let sideEffectFile: string; + + beforeEach(async () => { + origArgv1 = process.argv[1]; + sideEffectFile = path.join(tmpDir, 'cmd-ran.marker'); + }); + + afterEach(() => { + process.argv[1] = origArgv1; + delete process.env.TEAMAI_DISABLE_REMOTE_CMD; + }); + + // Write a throwaway node script and point the cmd entry resolver at it via + // process.argv[1], so runCmdCommand execs `node ` for real + // without needing a built teamai binary. + async function installHelper(bodyLines: string[]): Promise { + helperScript = path.join(tmpDir, 'fake-teamai-entry.mjs'); + await fse.writeFile(helperScript, bodyLines.join('\n')); + process.argv[1] = helperScript; + } + + interface AckBody { + id: number; + type: string; + status: string; + error?: string; + version?: string; + } + + // fetch mock that returns exactly one command from /sync and records the ack. + function stubFetchWithCommand(command: Record): { getAck: () => AckBody | undefined } { + let ackBody: AckBody | undefined; + const fetchMock = vi.fn(async (url: string, init?: { body?: string }) => { + if (url.includes('/commands/ack')) { + ackBody = JSON.parse(init?.body ?? '{}'); + return new Response(JSON.stringify({ ok: true })); + } + if (url.includes('/sync')) { + return new Response(JSON.stringify({ ok: true, commands: [command] })); + } + return new Response(JSON.stringify({ ok: true })); + }); + vi.stubGlobal('fetch', fetchMock); + return { getAck: () => ackBody }; + } + + it('runs a pushed teamai cmd and acks success', async () => { + await setupConfig(); + await installHelper([ + `import fs from 'node:fs';`, + `fs.writeFileSync(${JSON.stringify(sideEffectFile)}, 'ran');`, + `process.stdout.write('done');`, + ]); + const { getAck } = stubFetchWithCommand({ id: 7, type: 'cmd', cmd: 'teamai --version' }); + + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); + + expect(fs.existsSync(sideEffectFile)).toBe(true); + const ack = getAck(); + expect(ack).toBeDefined(); + if (!ack) return; + expect(ack.id).toBe(7); + expect(ack.type).toBe('cmd'); + expect(ack.status).toBe('success'); + }); + + it('rejects a non-teamai cmd without executing it and acks failed', async () => { + await setupConfig(); + await installHelper([ + `import fs from 'node:fs';`, + `fs.writeFileSync(${JSON.stringify(sideEffectFile)}, 'ran');`, + ]); + const { getAck } = stubFetchWithCommand({ id: 8, type: 'cmd', cmd: 'rm -rf /' }); + + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); + + expect(fs.existsSync(sideEffectFile)).toBe(false); + const ack = getAck(); + expect(ack).toBeDefined(); + if (!ack) return; + expect(ack.status).toBe('failed'); + expect(ack.error).toMatch(/only "teamai"/); + }); + + it('acks failed with "disabled" when TEAMAI_DISABLE_REMOTE_CMD=1', async () => { + await setupConfig(); + process.env.TEAMAI_DISABLE_REMOTE_CMD = '1'; + await installHelper([ + `import fs from 'node:fs';`, + `fs.writeFileSync(${JSON.stringify(sideEffectFile)}, 'ran');`, + ]); + const { getAck } = stubFetchWithCommand({ id: 9, type: 'cmd', cmd: 'teamai --version' }); + + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); + + expect(fs.existsSync(sideEffectFile)).toBe(false); + const ack = getAck(); + expect(ack).toBeDefined(); + if (!ack) return; + expect(ack.status).toBe('failed'); + expect(ack.error).toMatch(/disabled/); + }); + + it('acks failed with stderr detail when the subcommand exits non-zero', async () => { + await setupConfig(); + await installHelper([ + `process.stderr.write('boom happened');`, + `process.exit(3);`, + ]); + const { getAck } = stubFetchWithCommand({ id: 10, type: 'cmd', cmd: 'teamai explode' }); + + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ tool: 'claude', cwd: tmpDir }); + + const ack = getAck(); + expect(ack).toBeDefined(); + if (!ack) return; + expect(ack.status).toBe('failed'); + expect(ack.error).toMatch(/cmd failed/); + expect(ack.error).toMatch(/boom happened/); }); }); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 8d464985..2e80637b 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -98,7 +98,7 @@ function resolveCodebuddyNode(): string | null { * Resolve the teamai CLI entry script (dist/index.js) by walking up from * this module's location. Returns null when resolution fails. */ -function resolveTeamaiEntryScript(): string | null { +export function resolveTeamaiEntryScript(): string | null { try { const thisFile = fileURLToPath(import.meta.url); const distDir = path.dirname(thisFile); diff --git a/src/local-agent.ts b/src/local-agent.ts index 37553d84..8a3617e2 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -26,6 +26,7 @@ import { parseHookEvent } from './dashboard-collector.js'; import { getAgentVersion } from './agent-version.js'; import { getMachineId, deriveLocalAgentId } from './machine-id.js'; import { EXCLUDED_RULE_NAMES } from './builtin-rules.js'; +import { resolveTeamaiEntryScript } from './builtin-hooks.js'; import { assertSafeResourceName } from './utils/path-safety.js'; import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; @@ -158,6 +159,7 @@ interface LocalAgentCommand { name?: string; version?: string; display_name?: string; + cmd?: string; } interface LocalAgentContext { @@ -1648,11 +1650,138 @@ async function ackCommand( }); } +/** + * Tokenize a restricted `teamai` command string into an argv array. + * + * Supports single and double quotes so arguments containing spaces survive + * (e.g. `--name "a b"`). No variable expansion, no globbing; shell + * metacharacters like `;`, `|`, `&`, `$`, `(`, `)` are treated as literals. + * Throws when the string is empty, has an unterminated quote, or its first + * token is not exactly `teamai` — so a backend can never launch anything but + * a teamai subcommand. + */ +export function parseTeamaiCmd(raw: string): string[] { + const argv: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let hasToken = false; + for (const char of raw) { + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasToken = true; + continue; + } + if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { + if (hasToken) { + argv.push(current); + current = ''; + hasToken = false; + } + continue; + } + current += char; + hasToken = true; + } + if (quote) { + throw new Error('Unterminated quote in cmd'); + } + if (hasToken) { + argv.push(current); + } + if (argv.length === 0) { + throw new Error('Empty cmd'); + } + if (argv[0] !== 'teamai') { + throw new Error(`Rejected cmd: only "teamai" subcommands are allowed, got "${argv[0]}"`); + } + return argv; +} + +/** + * Resolve the teamai entry script to run a pushed cmd. Prefers the current + * process entry (`process.argv[1]`) so the running teamai is reused, and + * falls back to resolving `dist/index.js` from this bundle when argv[1] is + * unavailable (some sandboxed hook launchers). Returns null when neither + * resolves. + */ +function resolveCmdEntry(): string | null { + const argvEntry = process.argv[1]; + if (argvEntry) { + return argvEntry; + } + return resolveTeamaiEntryScript(); +} + +/** + * Execute a one-shot `type: 'cmd'` command pushed via sync. Runs a teamai + * subcommand once with the current Node binary (`process.execPath`) and the + * resolved entry script — no shell, so there is no metacharacter injection + * and no PATH dependency (works inside sandboxes with a bundled Node). The + * whole `process.env` is forwarded so bundled-node runtime variables survive. + * + * Throws (which the caller acks as `failed`) when remote cmd is disabled, the + * cmd is missing/rejected, the entry cannot be resolved, or the subprocess + * exits non-zero or times out. Returns undefined on success (no version to + * report for a cmd). + */ +async function runCmdCommand( + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + if (!command.cmd) { + throw new Error('cmd command is missing the "cmd" field'); + } + const argv = parseTeamaiCmd(command.cmd); + const entry = resolveCmdEntry(); + if (!entry) { + throw new Error('Cannot resolve teamai entry script to run cmd'); + } + const tag = localAgentTag(context); + try { + const { stdout } = await execFileAsync( + process.execPath, + [entry, ...argv.slice(1)], + { timeout: 120_000, env: process.env, maxBuffer: 4 * 1024 * 1024 }, + ); + const summary = stdout.trim().split('\n').slice(0, 3).join(' | '); + log.debug(`${tag} cmd OK: ${command.cmd}${summary ? ` — ${summary}` : ''}`); + return undefined; + } catch (e) { + const err = e as { stderr?: string; message?: string; killed?: boolean; code?: string }; + const detail = (err.stderr?.trim() || err.message || 'unknown error') + .split('\n') + .slice(0, 3) + .join(' | ') + .slice(0, 200); + // `killed` is also set on maxBuffer overflow, so disambiguate before labeling. + const prefix = err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + ? 'cmd output too large' + : err.killed + ? 'cmd timed out' + : 'cmd failed'; + throw new Error(`${prefix}: ${detail}`); + } +} + async function executeCommand( config: LocalAgentConfig, command: LocalAgentCommand, context: LocalAgentContext, ): Promise { + if (command.type === 'cmd') { + return runCmdCommand(command, context); + } const kind = commandKind(command); const action = commandAction(command); if (!kind || !action) { @@ -1720,38 +1849,53 @@ export async function reportAndSyncLocalAgent(context: LocalAgentContext): Promi } } - if (isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1') { + // CloudStudio sandbox reports a duplicate agent card (different machine id + // than the host), so we skip the report POST here. Sync + command execution + // must still run so sandboxed agents can receive pushed cmds (e.g. uninstall); + // sync produces no card, so there is no duplicate risk. + // TEAMAI_ALLOW_SANDBOX_REPORT=1 restores the report too (backward compatible). + const skipReport = isCloudStudioSandbox() && process.env.TEAMAI_ALLOW_SANDBOX_REPORT !== '1'; + if (skipReport) { log.debug( - '[local-agent] CloudStudio sandbox detected; skipping HTTP report/sync ' + - '(binding prompt still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to override)', + '[local-agent] CloudStudio sandbox detected; skipping HTTP report ' + + '(sync still runs; set TEAMAI_ALLOW_SANDBOX_REPORT=1 to report too)', ); - return false; } const tag = localAgentTag(context); log.debug(`${tag} run: endpoint=${config.endpoint}`); - if (context.event?.type === 'session_start') { - await maybeReconcilePlugins(context); - } + // Report-side bookkeeping (plugin reconcile + binding prune + tool stamp) is + // tied to the report path and must stay skipped inside the CloudStudio sandbox, + // exactly as before this branch stopped returning early. In particular, + // pruneDeadWorkspaceBindings would wrongly drop host bindings whose paths are + // not mounted in the container. Only sync + command execution run when + // skipReport is set. + if (!skipReport) { + if (context.event?.type === 'session_start') { + await maybeReconcilePlugins(context); + } - const pruned = await pruneDeadWorkspaceBindings(config); - // Resolve the current workspace independently here rather than reusing an - // earlier local, so tool attribution does not depend on the binding-prompt - // block above keeping a `workspacePath` in scope. - const currentPath = await resolveWorkspacePath(context.cwd); - const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); - if (pruned || stamped) { - await saveLocalAgentConfig(config); + const pruned = await pruneDeadWorkspaceBindings(config); + // Resolve the current workspace independently here rather than reusing an + // earlier local, so tool attribution does not depend on the binding-prompt + // block above keeping a `workspacePath` in scope. + const currentPath = await resolveWorkspacePath(context.cwd); + const stamped = stampWorkspaceTool(config, currentPath, context.tool ?? 'workbuddy'); + if (pruned || stamped) { + await saveLocalAgentConfig(config); + } } try { - const reportPayload = await buildReportPayload(config, context); - await localAgentFetch(config, tag, 'report', { - method: 'POST', - body: JSON.stringify(reportPayload), - }); - log.debug(`${tag} report OK`); + if (!skipReport) { + const reportPayload = await buildReportPayload(config, context); + await localAgentFetch(config, tag, 'report', { + method: 'POST', + body: JSON.stringify(reportPayload), + }); + log.debug(`${tag} report OK`); + } const syncPayload = await buildSyncPayload(config, context); const syncResponse = await localAgentFetch<{ ok?: boolean; commands?: LocalAgentCommand[] }>(