Skip to content

feat(http-source): push one-shot commands (type: "cmd") via sync + ack, sandbox-safe #235

Description

@jeff-r2026

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:

  1. 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).
  2. 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.
  3. 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

  1. No shell: execFile(process.execPath, [teamaiEntry, ...args]). Runs the current teamai entry script directly with node — no PATH dependency, no shell metacharacter injection.
  2. First-token allowlist: parsed argv[0] must strictly equal teamai (case-sensitive). Any other executable → rejected.
  3. 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.)
  4. Timeout: fixed subprocess timeout (e.g. 120s); on timeout, kill and ack failed, so one hung command cannot block subsequent syncs.
  5. 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)

  1. Normal cmd: sync pushes {type:'cmd', cmd:'teamai --version', id:1} → assert success + ack status=success.
  2. 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).
  3. Env disabled: with TEAMAI_DISABLE_REMOTE_CMD=1 → ack failed, error contains "disabled", not executed.
  4. Subcommand failure: cmd points to a teamai invocation that exits non-zero → ack failed, error contains stderr summary.
  5. 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)

  1. 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.
  2. 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

  1. npx tsc --noEmit + npx vitest run all green.
  2. Pure resource commands (install/uninstall skill/rule) behavior unchanged.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions