Skip to content
Merged

This file was deleted.

2 changes: 2 additions & 0 deletions .changelog/unreleased/fixed-363-runtimes-attested-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **CLI Claude Code, Codex and Gemini runners require launcher release or an interactive TTY `--no-sandbox` opt-out (Closes #363).**
Conflicting sandbox flags and values other than `true` or `false` are refused by name.
4 changes: 4 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ The `tps` CLI is the control plane for the Agent OS.
| `--config <path>` | Path to `openclaw.json` (defaults to auto-discovery). |
| `--version` | Show version number. |
| `--help` | Show help. |
| `--sandbox-required[=true|false]` / `--sandboxRequired[=true|false]` | Require isolation; conflicts with `--no-sandbox`. |
| `--no-sandbox` | Interactive TTY opt-out. `--sandbox=false` does not opt out. |

Sandbox flag values must be exactly `true` or `false`; other values are refused by name.

---

Expand Down
46 changes: 34 additions & 12 deletions packages/cli/bin/tps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import meow from "meow";
import { enforceLaunchControl, launchFlagDefinitions, readLaunchFlags } from "../src/utils/nono.js";

// Injected at compile time via --define flag; falls back to "dev" in dev mode.
declare const INJECTED_VERSION: string;
Expand All @@ -25,11 +26,7 @@ const FLAGS = {
// sandbox bypass. Renamed from --nonono (kept as a hidden deprecated alias).
quietNonoCheck: { type: "boolean", default: false },
nonono: { type: "boolean", default: false },
// Launch-path control (cli#341 S1a): --sandbox-required is asserted by every
// generated agent unit. (--no-sandbox is the interactive-TTY-only escape hatch;
// it is read from process.argv directly because yargs parses `--no-x` as a
// negation, which would shadow a declared `noSandbox` key.)
sandboxRequired: { type: "boolean", default: false },
...launchFlagDefinitions,
inject: { type: "boolean", default: true },
runtime: { type: "string", default: "openclaw" },
baseModel: { type: "string" },
Expand Down Expand Up @@ -129,6 +126,9 @@ const RAW_VALUE_FLAGS: Record<string, readonly string[]> = {

const helpArgs = parseHelpArgs(process.argv.slice(2));

const launchFlags = readLaunchFlags(process.argv);
if (launchFlags.refusal) enforceLaunchControl({ argv: process.argv });

const cli = meow(
`
Usage
Expand Down Expand Up @@ -166,6 +166,9 @@ const cli = meow(
--help Show this help text
--version Show version number
--config <path> Path to openclaw.json (default: auto-discover)
--sandbox-required[=true|false] Require isolation (alias: --sandboxRequired)
--no-sandbox Interactive TTY opt-out; conflicts with required isolation
Sandbox flag values: true or false only; --sandbox=false does not opt out

Examples
$ tps hire developer --name Fred
Expand Down Expand Up @@ -236,8 +239,8 @@ async function enforceLaunchControlOrExit(): Promise<void> {
confinement = { released: attestation.ok, reason: attestation.reason };
}
}
enforceLaunchControl({ command, rest, argv: process.argv, confinement });
if (process.argv.includes(NO_SANDBOX_FLAG) && isInteractiveTty()) {
enforceLaunchControl({ command, rest, argv: process.argv, parsedFlags: cli.flags, confinement });
if (launchFlags.noSandbox && isInteractiveTty()) {
console.warn(`⚠️ ${NO_SANDBOX_FLAG}: running WITHOUT nono isolation (interactive override).`);
}
}
Expand Down Expand Up @@ -278,7 +281,7 @@ const USAGE: Record<string, string> = {
" tps agent status --id <agent-id> [--json]\n" +
" tps agent decommission --id <agent-id> [--force]\n" +
" tps agent run --id <agent-id> --message <text>\n" +
" tps agent start --id <agent-id>\n" +
" tps agent start --id <agent-id> [--runtime <runtime>] [--sandbox-required[=true|false]] [--no-sandbox]\n" +
" tps agent health --id <agent-id>\n" +
" tps agent logs --id <agent-id> [--lines <N>] [--follow]\n" +
" tps agent healthcheck <agent-id>\n" +
Expand Down Expand Up @@ -545,7 +548,7 @@ async function main() {
" tps agent status --id <agent-id> [--json]\n" +
" tps agent decommission --id <agent-id> [--force]\n" +
" tps agent run --id <agent-id> --message <text>\n" +
" tps agent start --id <agent-id>\n" +
" tps agent start --id <agent-id> [--runtime <runtime>] [--sandbox-required[=true|false]] [--no-sandbox]\n" +
" tps agent health --id <agent-id>\n" +
" tps agent logs --id <agent-id> [--lines <N>] [--follow]\n" +
" tps agent healthcheck <agent-id>\n" +
Expand Down Expand Up @@ -641,8 +644,17 @@ async function main() {
const message = msgIdx >= 0 ? process.argv.slice(msgIdx + 1).join(" ") : undefined;
await runAgent({ action: "run", config: configPath, id: agentId, message });
} else if (action === "start") {
const runtimeArg = process.argv.includes("--runtime") ? process.argv[process.argv.indexOf("--runtime") + 1] : undefined;
if (runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini") {
const runtimeArg = process.argv.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined;
const attestedRuntime = runtimeArg === "claude-code" || runtimeArg === "codex" || runtimeArg === "gemini";
if (runtimeArg !== undefined && runtimeArg !== "openclaw" && !attestedRuntime) {
console.error(`❌ refusing to launch runtime '${runtimeArg}': unsupported runtime`);
process.exit(78);
}
const sandboxed = process.argv.includes("--sandboxed");
const noSandbox = launchFlags.noSandbox;
// Selected runners execute after launcher release or an interactive
// TTY `--no-sandbox` opt-out.
if (attestedRuntime && (sandboxed || noSandbox)) {
// Claude Code CLI runtime — OAuth, no TPS proxy needed
const { join } = await import("node:path");
const { homedir } = await import("node:os");
Expand Down Expand Up @@ -764,7 +776,17 @@ async function main() {
if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`);
}
} else {
await runAgent({ action: "start", config: configPath, id: agentId, sandbox: !process.argv.includes("--no-sandbox"), sandboxed: process.argv.includes("--sandboxed"), sandboxRequired: process.argv.includes("--sandbox-required") });
await runAgent({
action: "start",
config: configPath,
id: agentId,
sandbox: !noSandbox,
sandboxed,
sandboxRequired: launchFlags.sandboxRequired,
// Carry the runtime into the re-exec so the sandboxed child runs the
// runtime runner (cli#363 slice B); undefined for the default path.
runtime: attestedRuntime ? runtimeArg : undefined,
});
}
} else {
await runAgent({ action: "health", config: configPath, id: agentId });
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/nono-profiles/tps-agent-run-claude-code.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://nono.sh/schemas/nono-profile.schema.json",
"extends": "tps-agent-run",
"meta": {
"name": "tps-agent-run-claude-code"
},
"groups": {
"exclude": [
"system_read_macos"
]
}
}
12 changes: 12 additions & 0 deletions packages/cli/nono-profiles/tps-agent-run-codex.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://nono.sh/schemas/nono-profile.schema.json",
"extends": "tps-agent-run",
"meta": {
"name": "tps-agent-run-codex"
},
"groups": {
"exclude": [
"system_read_macos"
]
}
}
12 changes: 12 additions & 0 deletions packages/cli/nono-profiles/tps-agent-run-gemini.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://nono.sh/schemas/nono-profile.schema.json",
"extends": "tps-agent-run",
"meta": {
"name": "tps-agent-run-gemini"
},
"groups": {
"exclude": [
"system_read_macos"
]
}
}
42 changes: 28 additions & 14 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
isSupervised,
harnessReadPaths,
harnessReadFiles,
runtimeNonoOptions,
runtimeNonoProfile,
REFUSAL_EXIT_CODE,
SUPERVISED_REFUSAL_EXIT_CODE,
} from "../utils/nono.js";
Expand Down Expand Up @@ -61,6 +63,12 @@ export interface AgentArgs {
sandboxed?: boolean;
/** The launch unit asserted --sandbox-required; carry it into the re-exec. */
sandboxRequired?: boolean;
/**
* The selected agent runtime (`--runtime claude-code|codex|gemini`). Carried
* into the attested re-exec so the sandboxed child runs the runtime runner
* (cli#363 slice B); undefined for the default AgentRuntime path.
*/
runtime?: string;
lines?: number;
follow?: boolean;
ackScopeExpansion?: boolean;
Expand Down Expand Up @@ -814,6 +822,10 @@ export async function runAgent(args: AgentArgs): Promise<void> {
const sandbox = (args as any).sandbox ?? true; // default ON — nono is the required isolation layer
const sandboxed = (args as any).sandboxed ?? false;
const sandboxRequired = (args as any).sandboxRequired ?? process.argv.includes("--sandbox-required");
// cli#363 slice B: carry the selected runtime into the re-exec so the
// sandboxed child runs the runtime runner rather than the default
// AgentRuntime. Its CLI caller selects only those three runtimes.
const selectedRuntime = args.runtime;
// The pinned ABSOLUTE path (never PATH) — the same resolution the
// launcher performs, so the decision to launch and the launch itself
// cannot disagree about which nono is in play (cli#350 round 4e). Using
Expand All @@ -827,20 +839,16 @@ export async function runAgent(args: AgentArgs): Promise<void> {
// to runtime. Whether that claim is TRUE was settled by the gate: this
// process only reaches here holding the launcher's release
// (cli#350 round 4e).
} else if (sandbox || isNonoStrict()) {
} else if (sandbox || selectedRuntime || isNonoStrict()) {
if (!nonoAvailable) {
// Fail closed, in every context the launch control governs. A
// non-interactive launch MUST NOT fall back to running the agent
// unsandboxed: that is the silent no-op the unit cannot see (under
// TPS_SUPERVISED the refusal exits 0 and KeepAlive never
// relaunches). An interactive human keeps the old warning — they
// can see it and decide.
if (isNonoStrict() || sandboxRequired || !isInteractiveTty()) {
const why = isNonoStrict()
? "TPS_NONO_STRICT=1"
: "this launch is not interactive";
if (selectedRuntime || isNonoStrict() || sandboxRequired || !isInteractiveTty()) {
const why = selectedRuntime
? `runtime '${selectedRuntime}' requires isolation; use --no-sandbox in an interactive TTY to opt out`
: isNonoStrict()
? "TPS_NONO_STRICT=1"
: "this launch is not interactive";
console.error(
`❌ refusing to launch the agent: no nono at the pinned absolute path ` +
`❌ refusing to launch ${selectedRuntime ? `runtime '${selectedRuntime}'` : "the agent"}: no nono at the pinned absolute path ` +
`(${resolveNonoBinary().reason ?? "unknown"}) — ${why}, so the agent cannot ` +
`run without isolation. Install nono >= 0.70 or set NONO_BIN.`
);
Expand Down Expand Up @@ -869,13 +877,18 @@ export async function runAgent(args: AgentArgs): Promise<void> {
"--sandboxed",
];
if (sandboxRequired) relaunch.push("--sandbox-required");
// Carry the runtime through the attested launch (cli#363 slice B):
// the sandboxed child re-enters bin/tps.ts, which runs the runtime
// runner on the execution side.
if (selectedRuntime) relaunch.push("--runtime", selectedRuntime);
// THE ATTESTED LAUNCH (cli#350 round 4e): the launcher creates the
// private dir, plants the canaries, spawns nono by absolute path,
// and releases the child over its own socket only after `nono ps`
// binds a live session to the pid it spawned and to the pid the
// child reports, with the OUTSIDE canary still unreadable to it.
const runtimeGrants = runtimeNonoOptions(selectedRuntime);
const exitCode = await launchAttested(
"tps-agent-run",
runtimeNonoProfile(selectedRuntime),
{
workdir: config.workspace,
// CHANGE (cli#341 S1b): this used to grant a read of the
Expand All @@ -887,12 +900,13 @@ export async function runAgent(args: AgentArgs): Promise<void> {
// Exactly this agent's own identity files, not the shared
// identity directory (cli#351 r4).
readFiles: harnessReadFiles(launchId),
allowFiles: runtimeGrants.allowFiles,
// Bun's own temp dir is /tmp regardless of TMPDIR, and an
// unreadable temp dir is fatal to it — grant BOTH /tmp and the
// configured TMPDIR (cli#350 r4g). On macOS launchd sets TMPDIR
// to /var/folders/…, so /tmp would otherwise not be granted at
// all; on Linux TMPDIR is usually /tmp and the Set dedupes.
allow: [...new Set([mailDir, tmpDir, "/tmp", config.workspace, agentDir])],
allow: [...new Set([mailDir, tmpDir, "/tmp", config.workspace, agentDir, ...(runtimeGrants.allow ?? [])])],
},
relaunch,
);
Expand Down
17 changes: 3 additions & 14 deletions packages/cli/src/utils/launch-attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,8 @@
* AND to the pid the child reports, with enforcement verified BEHAVIOURALLY from
* outside the sandbox — or the child is never released.
*
* KNOWN EXEMPTION, stated here so this comment does not overstate its own reach
* (found in review of the v0.6.0 release, 2026-09-17): `tps agent start
* --runtime claude-code|codex|gemini` branches in `bin/tps.ts` BEFORE reaching
* `runAgent`, and spawns the runtime directly — so it never arrives here and is
* NOT confined by nono. `launchesAgent()` (`nono.ts`) still keys on the command
* name, so that path still reads as an agent launch to the gate; the gate
* therefore refuses an invocation carrying `--sandbox-required` on it before
* dispatch, unless an earlier launch control has already refused it (cli#363
* slice A), because the
* flag asserts an isolation the path cannot deliver. Do not read "every launch
* through `tps agent start`" anywhere in this file or the release notes as
* covering those three runtimes. Routing them through this attestation is
* tracked in cli#363.
* CLI selected runtime runners require launcher release or an interactive TTY
* `--no-sandbox` opt-out. Conflicting `--sandbox-required` is refused by the gate.
*
* Why behavioural, not a nono audit record (round 4e): nono 0.74.0 writes its
* per-session `sandbox_runtime` audit record ONLY when tool-sandbox is active
Expand Down Expand Up @@ -282,7 +271,7 @@ export function grantsOfOptions(
workdir: options.workdir,
cwd,
read: [...(options.read ?? [])],
readFiles: [...(options.readFiles ?? [])],
readFiles: [...(options.readFiles ?? []), ...(options.allowFiles ?? [])],
allow: [...(options.allow ?? []), ...extraAllow],
};
}
Expand Down
Loading
Loading