Skip to content

refactor(agentic): machine-runner is a remote control; Claude/Codex adapters move above it into agent-cli - #1861

Merged
pyramation merged 2 commits into
mainfrom
feat/machine-runner-remote-control
Sep 25, 2026
Merged

pyramation merged 2 commits into
mainfrom
feat/machine-runner-remote-control

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Replaces what #1860 moved here — an agent-aware runner — with the decoupled packages merged in constructive-io/constructive-db#3855, so what gets published from this repo is the corrected layering:

@constructive-db/machine-protocol  wire frames + bounded LineSplitter + parseApprovalDecisionLine
@constructive-db/machine-runner    remote control: enroll → run allow-listed command (pty|pipes)
                                   → bytes in/out → resize/signal → detach/reattach → exit.
                                   Knows nothing about agents.
@constructive-db/agent-cli   (new) `constructive-agent-cli <claude|codex> [--resume id] [-- args]`
                                   Claude/Codex adapters, AgentEvent JSON lines out, prompts +
                                   approval-decision lines in. An ordinary command the runner runs.

Removed from machine-runner: agent-cli.ts, cli-session.ts, headless-session.ts, agent bindings/approvals in runner.ts/policy.ts, and the config.ts agent knobs. The relay (still in constructive-db) composes the final command, e.g. cli binding → constructive-agent-cli claude --resume <id> -- <args>.

Tests: unit suites ship here (protocol codec/LineSplitter 20, runner cli/config/policy/spawn-helper 22, agent-cli adapters 5 incl. the codex -- prompt-injection regression). The runner and agent-cli end-to-end proofs need @constructive-db/machine-relay, which stays in constructive-db, so they remain there. agentic/agent-cli is added to the agentic CI batch.

Versions are left for lerna version/publish from main. Note: main already carries a chore(release): publish bumping machine-protocol/runner to 0.2.0, but npm still only has 0.1.0 of each and no agent-cli — the previous publish did not land.

Link to Devin session: https://app.devin.ai/sessions/3c993d055ffb47f6be27862718a9cd42
Open in Devin Desktop: https://app.devin.ai/desktop/session/3c993d055ffb47f6be27862718a9cd42?variant=devin
Requested by: @pyramation

…dapters move above it into agent-cli

Mirrors constructive-io/constructive-db#3855. The runner enrolls, runs
allow-listed commands in a pty or pipes, streams bytes, resizes, signals,
detaches/reattaches — and knows nothing about agents. Claude/Codex
adapters, the agent stdio contract and approval decisions live in the new
@constructive-db/agent-cli (constructive-agent-cli). The protocol gains
the bounded LineSplitter and approval-decision line parser.

Runner/agent-cli end-to-end proofs need the relay and stay in
constructive-db; unit tests ship here.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 3 medium

💬 Inline comments (3)

  • 🟡 Symlink escape bypasses resolveCwd policy root — policy.ts:53
  • 🟡 Guard prompt path against spawn after session settled — session.ts:209
  • 🟡 Treat spawn error as terminal in pipeProcess — process.ts:76
🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 LineSplitter.push drops complete lines when it throws (index.ts:378) — When the buffered tail exceeds maxLineLength, LineSplitter.push throws after splitting the chunk but before returning the completed lines (agentic/machine-protocol/src/index.ts:378-389), so every fully terminated line in that chunk is discarded along with the oversized tail and the splitter is reset.

The change moves agent-adapter and session logic from machine-runner into a new standalone agentic/agent-cli package, adds a -- separator guard against argv injection in adapters, and simplifies the runner by replacing the headless session with a piped child-process helper. CI is updated to test the new package.

Files Change
agentic/agent-cli/* New package: adapters for claude/codex CLIs, session/approval translation layer, run.ts entrypoint, tests and fixtures
agentic/machine-runner/src/{runner,process,config,policy}.ts Refactor: runner delegates agent sessions to the CLI package via new pipeProcess; policy retains resolveCwd confinement and approval decisions
agentic/machine-protocol/src/index.ts Protocol codec with LineSplitter framing for stdout/stderr events
.github/workflows/run-tests.yaml Adds agentic/agent-cli to the test batch

Reviewed commit: aa456c1

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR extracts the coding-agent CLI into a new agentic/agent-cli package and refactors machine-runner to delegate to it via pipeProcess, tightening approval policy and cwd confinement along the way.

Key findings

  • 🟡 Symlink escape bypasses resolveCwd policy root — policy.ts:53
  • 🟡 Guard prompt path against spawn after session settled — session.ts:209
  • 🟡 Treat spawn error as terminal in pipeProcess — process.ts:76

Comment on lines +53 to +55
const resolved = path.resolve(root, requested);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
throw new PolicyViolationError(`cwd '${requested}' is outside the policy root`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Symlink escape bypasses resolveCwd policy root

The cwd containment check in resolveCwd (agentic/machine-runner/src/policy.ts:50-58) is purely lexical: it compares path.resolve(root, requested) against a startsWith(root + path.sep) prefix and never resolves symlinks. A relay-supplied frame.cwd reaches it via resolveSpawn in openSession (agentic/machine-runner/src/runner.ts:367). A session running an allowed command inside the root can plant a symlink such as inside-link -> /etc and then request cwd: 'inside-link'; the lexical check passes while the spawned process works outside the policy root, defeating the machine owner's confinement.

📋 Prompt for AI Agents

In agentic/machine-runner/src/policy.ts around lines 50-58, make resolveCwd symlink-proof: after path.resolve(root, requested), canonicalize both the policy root and the resolved path with fs.realpath (async or realpathSync.native), falling back to the realpath of the deepest existing ancestor directory when the target does not exist yet, and only then apply the startsWith(root + path.sep) containment check. Add tests in agentic/machine-runner/tests/policy.test.ts covering a symlink inside the root that points outside being rejected.

Comment on lines +209 to +211
if (!child) {
start(line);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Guard prompt path against spawn after session settled

onInputLine spawns the CLI via start(line) whenever child is null, but never checks exited (agentic/agent-cli/src/session.ts:209). When the abort signal fires before any prompt arrived, the listener resolves the session with { exitCode: -1, signal: 'SIGTERM' } (agentic/agent-cli/src/session.ts:220-228) while the stdin data/end listeners stay attached, so a prompt line buffered or flushed afterwards still spawns claude/codex. The caller has already been told the session ended, so the freshly spawned CLI runs as an unsupervised orphan; the same applies after fail() rejects.

📋 Prompt for AI Agents

In agentic/agent-cli/src/session.ts, guard the prompt path so no child is spawned after the session has concluded: in onInputLine (around line 200) return early when exited is true before start(line) and before any child.stdin.write; additionally, in the abort-without-child branch (lines 220-228) and in fail() (lines 81-88), remove the io.stdin 'data'/'end'/'error' listeners or call io.stdin.destroy(), so stdin bytes arriving after the promise settles cannot spawn or feed a CLI child.

Comment on lines +76 to +78
child.on('error', err => {
for (const listener of errorListeners) listener(err);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Treat spawn error as terminal in pipeProcess

When spawnChild fails to start the program (ENOENT, EACCES), Node emits child.on('error') and never exit, so exited stays false in pipeProcess (agentic/machine-runner/src/process.ts:76-78) and no onExit listener ever runs. The runner's onError handler deletes the session and sends an error frame but no exit frame, and it never kills the process (agentic/machine-runner/src/runner.ts:404-408), so the relay sees a session that failed but formally never ended. Because exited stays false, later write() calls still hit child.stdin and re-emit EPIPE into errorListeners, producing duplicate error frames for an already-deleted session, and the child (if it did start) is left running as an orphan.

📋 Prompt for AI Agents

In agentic/machine-runner/src/process.ts, in the child.on('error', ...) handler (around line 76), set exited = true, invoke errorListeners once, then synthesize an exit event for exitListeners (e.g. { exitCode: -1 }) unless one was already delivered, so a spawn failure is terminal, write()/kill() become no-ops, and the runner receives one terminal report. In agentic/machine-runner/src/runner.ts in the proc.onError callback in openSession (lines 404-408), call proc.kill() before this.sessions.delete(sessionId) so an asynchronously failed pipe child is not left running as an orphan.

… no CLI spawn after the session concludes, complete lines survive an oversized tail
@pyramation
pyramation merged commit 6eb1588 into main Sep 25, 2026
20 checks passed
@pyramation
pyramation deleted the feat/machine-runner-remote-control branch September 25, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant