Summary
Add a new one-shot command type to the HTTP source's sync channel. Beyond the existing install/uninstall resource commands, the backend should be able to push a one-shot command to run once on the client (e.g. an uninstall), with the result reported back via the existing ack channel.
{ "type": "cmd", "cmd": "teamai uninstall --agent claude", "id": 42 }
Background: the pipeline already exists
The command delivery/execution/ack skeleton is already in place — only a new type branch is missing:
sync returns commands[] → processCommands (local-agent.ts:1623) executes each one, and already acks both success and failure (ackCommand → /api/local-agent/commands/ack, carrying id/type/status/error/version).
executeCommand (local-agent.ts:1594) currently only recognizes resource commands (install/uninstall skill/rule/claudemd); type: 'cmd' falls through to Unsupported command type and acks failed.
- So this feature = add a
type === 'cmd' branch to executeCommand. Delivery, ack, error reporting, and retry semantics are all reused — nothing needs to be moved.
Decisions (agreed)
- Execution scope:
teamai subcommands only. The first token of the cmd string must be teamai; the rest is passed as argv to execFile (no shell). The backend cannot trigger an arbitrary shell — no RCE surface. A non-teamai prefix → ack failed (rejected, not executed).
- On by default, disable via env. Consistent with the existing trust model where install/uninstall commands run automatically.
TEAMAI_DISABLE_REMOTE_CMD=1 lets a user/admin disable it; when disabled, ack failed with error remote cmd disabled by client so the backend can observe the rejection instead of a silent drop.
- Sandbox delivery: skip report, still sync. See "Sandbox / restricted environments" below.
Security design
- No shell:
execFile(process.execPath, [teamaiEntry, ...args]). Runs the current teamai entry script directly with node — no PATH dependency, no shell metacharacter injection.
- First-token allowlist: parsed
argv[0] must strictly equal teamai (case-sensitive). Any other executable → rejected.
- Tokenization: minimal tokenizer (single/double quotes supported; no variable expansion, no glob, metacharacters like
;|&&$() treated as literals). Tokenization failure → rejected. (Lean toward a hand-written restricted tokenizer for zero new deps + controlled semantics.)
- Timeout: fixed subprocess timeout (e.g. 120s); on timeout, kill and ack failed, so one hung command cannot block subsequent syncs.
- Env kill switch:
TEAMAI_DISABLE_REMOTE_CMD=1 short-circuits and acks failed.
Implementation steps
1. src/local-agent.ts — types
- Add
cmd?: string; to the LocalAgentCommand interface (near line 142).
2. src/local-agent.ts — tokenizer + executor
parseTeamaiCmd(raw: string): string[]: restricted tokenizer returning argv. Validate non-empty and first token === teamai; otherwise throw (message flows into the ack error).
resolveTeamaiEntry(): string: resolve the current teamai entry script (process.argv[1]; fall back to deriving dist/index.js from import.meta.url).
runCmdCommand(command, context): Promise<string | undefined>:
- If
process.env.TEAMAI_DISABLE_REMOTE_CMD === '1' → throw remote cmd disabled by client (→ ack failed).
parseTeamaiCmd(command.cmd) → argv.
execFile(process.execPath, [entry, ...argv.slice(1)], { timeout: 120_000 }).
- Success: debug-log stdout summary, return undefined (version is meaningless here).
- Failure/timeout: throw with stderr summary (→ ack failed, error includes summary).
3. src/local-agent.ts — wire into executeCommand (line 1594)
- Before resolving kind/action, check
command.type === 'cmd' first:
if (command.type === 'cmd') {
return runCmdCommand(command, context);
}
(Placed first so commandKind/commandAction don't misclassify 'cmd'.)
- Resource logic otherwise unchanged.
4. Ack semantics (reuse existing, no signature change)
- Success:
ackCommand(..., 'success', undefined) (handled by existing processCommands).
- Failure/disabled/rejected: existing catch branch acks
'failed' with error — no change needed.
- Note: keep protocol status at
success|failed, no new status, to minimize backend integration cost.
4b. Sandbox / restricted environments (codex & workbuddy must work too) ★
Sandbox and GUI-tool hook subprocesses (WorkBuddy / CodeBuddy / CloudStudio container) have no teamai on PATH and use their own bundled node. The cmd path must still be able to launch teamai subcommands in these environments:
-
Do not rely on teamai in PATH (absent in sandboxes). Use execFile(node, [entry, ...args]).
-
node = process.execPath: hook-dispatch is itself launched by the ~/.teamai/bin/teamai wrapper using the bundled node (builtin-hooks.ts:132 exec "<bundled-node>" "<dist/index.js>"), so the current process's process.execPath is already the correct node (WorkBuddy bundled / CodeBuddy bundled / Claude·Codex system node all covered) — no re-detection needed.
-
entry = process.argv[1], fallback to deriving dist/index.js from import.meta.url (reuse resolveTeamaiEntryScript at builtin-hooks.ts:101 — export it or extract to a shared util rather than duplicating).
-
Pass through env: forward the current process.env to the subprocess so bundled-node runtime variables aren't lost and the command can initialize inside the sandbox.
-
CloudStudio sandbox guard: reportAndSyncLocalAgent currently skips report/sync entirely when isCloudStudioSandbox() (local-agent.ts:1666), unless TEAMAI_ALLOW_SANDBOX_REPORT=1. That means a CloudStudio container never receives synced cmds and the uninstall command can't be delivered.
- The guard exists only to avoid a duplicate agent card (report-level); it is unrelated to running ops commands.
- Decided approach: change the guard from "skip report+sync entirely" to "skip report, still run sync + processCommands". Sync produces no agent card, so there's no duplicate risk, and sandboxed codex / workbuddy agents can receive and run pushed commands.
- Concretely (near
local-agent.ts:1666): the sandbox branch no longer return false directly; instead set a skipReport = true flag. In the try block below: when skipReport, do not send report (skip buildReportPayload + report fetch), but still send sync and processCommands. TEAMAI_ALLOW_SANDBOX_REPORT=1 keeps its original meaning (send report too, i.e. skipReport = false), backward-compatible.
- Boundary: the binding-prompt block (stdout hook, not HTTP) is unchanged; workspace-binding persistence keeps its existing logic (unaffected by
skipReport).
5. Docs (bilingual, keep in sync)
- HTTP source / local-agent protocol docs: add the
cmd command format, security boundary (teamai subcommands only, no shell), and the TEAMAI_DISABLE_REMOTE_CMD switch.
- README / README.zh-CN if they describe HTTP source command delivery.
- Any local-agent protocol design doc under
docs/designs/: add the cmd command definition.
Test plan (each item actually executed)
Unit tests src/__tests__/local-agent.test.ts (new describe block)
- Normal cmd: sync pushes
{type:'cmd', cmd:'teamai --version', id:1} → assert success + ack status=success.
- Non-teamai prefix rejected:
cmd:'rm -rf /' → ack failed, error contains rejection reason, and not executed (spy execFile not called with it / no side effect).
- Env disabled: with
TEAMAI_DISABLE_REMOTE_CMD=1 → ack failed, error contains "disabled", not executed.
- Subcommand failure: cmd points to a teamai invocation that exits non-zero → ack failed, error contains stderr summary.
- Quote tokenization:
cmd:'teamai foo --name "a b"' → argv correctly split to ['teamai','foo','--name','a b'] (test parseTeamaiCmd directly).
End-to-end (after npm run build, real CLI)
- Stand up a local mock HTTP endpoint (returns one
cmd command, receives ack), teamai init --http <endpoint> (or configured side-channel) → trigger hook-dispatch → verify the teamai subcommand actually ran and the ack request was sent with correct status.
- User's example: push
teamai uninstall --agent claude (once uninstall --agent lands) → claude resources uninstalled, ack success. (If uninstall --agent isn't in yet, use teamai --version / teamai pull to verify the cmd chain; defer the uninstall integration test until that feature merges.)
Sandbox / restricted environments (this feature's new requirement) ★
7b. No-PATH scenario: run the cmd chain with PATH cleared (or minimal) → verify teamai subcommands still launch (proving it uses process.execPath + entry, not teamai on PATH).
7c. codex environment: trigger hook-dispatch with --tool codex, push a cmd → verify success + ack success.
7d. CloudStudio sandbox: set X_IDE_IS_CLOUDSTUDIO=TRUE to simulate a container → per the 4b approach, verify sync still fetches and runs the cmd (report may be skipped), ack reported normally.
7e. workbuddy bundled-node scenario: if WorkBuddy bundled node is reproducible locally, trigger via the ~/.teamai/bin/teamai wrapper → verify cmd runs with bundled node; if not reproducible, at least assert the execFile node arg === process.execPath.
Regression
npx tsc --noEmit + npx vitest run all green.
- Pure resource commands (install/uninstall skill/rule) behavior unchanged.
- Non-sandbox environment (Claude, system node) cmd execution unaffected by sandbox changes.
Out of scope (avoid over-engineering)
- ❌ No arbitrary shell commands / arbitrary executables (teamai subcommands only).
- ❌ No new ack status (keep
success|failed).
- ❌ No command queue persistence / idempotency dedup (rely on backend id + existing sync semantics).
- ❌ No interactive commands / commands needing stdin (execFile has no stdin input).
Summary
Add a new one-shot command type to the HTTP source's sync channel. Beyond the existing install/uninstall resource commands, the backend should be able to push a one-shot command to run once on the client (e.g. an uninstall), with the result reported back via the existing ack channel.
{ "type": "cmd", "cmd": "teamai uninstall --agent claude", "id": 42 }Background: the pipeline already exists
The command delivery/execution/ack skeleton is already in place — only a new
typebranch is missing:syncreturnscommands[]→processCommands(local-agent.ts:1623) executes each one, and already acks both success and failure (ackCommand→/api/local-agent/commands/ack, carryingid/type/status/error/version).executeCommand(local-agent.ts:1594) currently only recognizes resource commands (install/uninstall skill/rule/claudemd);type: 'cmd'falls through toUnsupported command typeand acks failed.type === 'cmd'branch toexecuteCommand. Delivery, ack, error reporting, and retry semantics are all reused — nothing needs to be moved.Decisions (agreed)
teamaisubcommands only. The first token of thecmdstring must beteamai; the rest is passed as argv toexecFile(no shell). The backend cannot trigger an arbitrary shell — no RCE surface. A non-teamaiprefix → ack failed (rejected, not executed).TEAMAI_DISABLE_REMOTE_CMD=1lets a user/admin disable it; when disabled, ackfailedwith errorremote cmd disabled by clientso the backend can observe the rejection instead of a silent drop.Security design
execFile(process.execPath, [teamaiEntry, ...args]). Runs the current teamai entry script directly with node — no PATH dependency, no shell metacharacter injection.argv[0]must strictly equalteamai(case-sensitive). Any other executable → rejected.;|&&$()treated as literals). Tokenization failure → rejected. (Lean toward a hand-written restricted tokenizer for zero new deps + controlled semantics.)TEAMAI_DISABLE_REMOTE_CMD=1short-circuits and acks failed.Implementation steps
1.
src/local-agent.ts— typescmd?: string;to theLocalAgentCommandinterface (near line 142).2.
src/local-agent.ts— tokenizer + executorparseTeamaiCmd(raw: string): string[]: restricted tokenizer returning argv. Validate non-empty and first token ===teamai; otherwise throw (message flows into the ack error).resolveTeamaiEntry(): string: resolve the current teamai entry script (process.argv[1]; fall back to derivingdist/index.jsfromimport.meta.url).runCmdCommand(command, context): Promise<string | undefined>:process.env.TEAMAI_DISABLE_REMOTE_CMD === '1'→ throwremote cmd disabled by client(→ ack failed).parseTeamaiCmd(command.cmd)→ argv.execFile(process.execPath, [entry, ...argv.slice(1)], { timeout: 120_000 }).3.
src/local-agent.ts— wire intoexecuteCommand(line 1594)command.type === 'cmd'first:commandKind/commandActiondon't misclassify'cmd'.)4. Ack semantics (reuse existing, no signature change)
ackCommand(..., 'success', undefined)(handled by existingprocessCommands).'failed'with error — no change needed.success|failed, no new status, to minimize backend integration cost.4b. Sandbox / restricted environments (codex & workbuddy must work too) ★
Sandbox and GUI-tool hook subprocesses (WorkBuddy / CodeBuddy / CloudStudio container) have no
teamaion PATH and use their own bundled node. The cmd path must still be able to launch teamai subcommands in these environments:Do not rely on
teamaiin PATH (absent in sandboxes). UseexecFile(node, [entry, ...args]).node =
process.execPath: hook-dispatch is itself launched by the~/.teamai/bin/teamaiwrapper using the bundled node (builtin-hooks.ts:132exec "<bundled-node>" "<dist/index.js>"), so the current process'sprocess.execPathis already the correct node (WorkBuddy bundled / CodeBuddy bundled / Claude·Codex system node all covered) — no re-detection needed.entry =
process.argv[1], fallback to derivingdist/index.jsfromimport.meta.url(reuseresolveTeamaiEntryScriptatbuiltin-hooks.ts:101— export it or extract to a shared util rather than duplicating).Pass through env: forward the current
process.envto the subprocess so bundled-node runtime variables aren't lost and the command can initialize inside the sandbox.CloudStudio sandbox guard:
reportAndSyncLocalAgentcurrently skips report/sync entirely whenisCloudStudioSandbox()(local-agent.ts:1666), unlessTEAMAI_ALLOW_SANDBOX_REPORT=1. That means a CloudStudio container never receives synced cmds and the uninstall command can't be delivered.local-agent.ts:1666): the sandbox branch no longerreturn falsedirectly; instead set askipReport = trueflag. In the try block below: whenskipReport, do not send report (skipbuildReportPayload+ report fetch), but still send sync and processCommands.TEAMAI_ALLOW_SANDBOX_REPORT=1keeps its original meaning (send report too, i.e.skipReport = false), backward-compatible.skipReport).5. Docs (bilingual, keep in sync)
cmdcommand format, security boundary (teamai subcommands only, no shell), and theTEAMAI_DISABLE_REMOTE_CMDswitch.docs/designs/: add the cmd command definition.Test plan (each item actually executed)
Unit tests
src/__tests__/local-agent.test.ts(new describe block){type:'cmd', cmd:'teamai --version', id:1}→ assert success + ack status=success.cmd:'rm -rf /'→ ack failed, error contains rejection reason, and not executed (spy execFile not called with it / no side effect).TEAMAI_DISABLE_REMOTE_CMD=1→ ack failed, error contains "disabled", not executed.cmd:'teamai foo --name "a b"'→ argv correctly split to['teamai','foo','--name','a b'](testparseTeamaiCmddirectly).End-to-end (after
npm run build, real CLI)cmdcommand, receives ack),teamai init --http <endpoint>(or configured side-channel) → trigger hook-dispatch → verify the teamai subcommand actually ran and the ack request was sent with correct status.teamai uninstall --agent claude(once uninstall --agent lands) → claude resources uninstalled, ack success. (If uninstall --agent isn't in yet, useteamai --version/teamai pullto verify the cmd chain; defer the uninstall integration test until that feature merges.)Sandbox / restricted environments (this feature's new requirement) ★
7b. No-PATH scenario: run the cmd chain with
PATHcleared (or minimal) → verify teamai subcommands still launch (proving it usesprocess.execPath+ entry, notteamaion PATH).7c. codex environment: trigger hook-dispatch with
--tool codex, push a cmd → verify success + ack success.7d. CloudStudio sandbox: set
X_IDE_IS_CLOUDSTUDIO=TRUEto simulate a container → per the 4b approach, verify sync still fetches and runs the cmd (report may be skipped), ack reported normally.7e. workbuddy bundled-node scenario: if WorkBuddy bundled node is reproducible locally, trigger via the
~/.teamai/bin/teamaiwrapper → verify cmd runs with bundled node; if not reproducible, at least assert the execFile node arg ===process.execPath.Regression
npx tsc --noEmit+npx vitest runall green.Out of scope (avoid over-engineering)
success|failed).