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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,19 @@ When using `teamai init --http <baseUrl>`, 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 |
Expand All @@ -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

Expand Down
18 changes: 16 additions & 2 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

可配置环境变量:

| 变量 | 作用 |
Expand All @@ -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` 重新开启上报。

### 代码知识图谱
Expand Down
214 changes: 200 additions & 14 deletions src/__tests__/local-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 <helper> <args>` for real
// without needing a built teamai binary.
async function installHelper(bodyLines: string[]): Promise<void> {
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<string, unknown>): { 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/);
});
});
2 changes: 1 addition & 1 deletion src/builtin-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading