Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
56b3852
fix(cli): keep --config through the attested re-exec, run worktree se…
tps-flint Oct 15, 2026
6d47e41
test(cli): the #483 re-exec fixture asserts the carried runtime by po…
tps-flint Oct 15, 2026
04baff6
docs(cli): the #516 fragment says tps's own arguments
tps-flint Oct 15, 2026
472f87b
fix(cli): resolve --config once, neutralise git hooks for parent-side…
tps-flint Oct 16, 2026
a12d20a
test(cli): cover the --sandboxed gate against a payload token (cli#516)
tps-flint Oct 16, 2026
c42fb6b
docs(cli): narrow the #516 claims to what the change and its tests es…
tps-flint Oct 16, 2026
a7b0c80
fix(cli): create the worktree unchecked out in the parent and check i…
tps-flint Oct 16, 2026
6230617
fix(cli): keep the credential overlap and profile gates ahead of work…
tps-flint Oct 16, 2026
ef292b0
docs(cli): name the three executables the parent-side worktree add wo…
tps-flint Oct 16, 2026
9e8cb35
round: address the review verdict (Codex fix round)
tps-flint Oct 16, 2026
377e829
docs: the parent creates a worktree only when a base repository exist…
tps-flint Oct 16, 2026
7e42faf
test(cli): run the real-nono cli#516 cases from a fixture HOME outsid…
tps-flint Oct 16, 2026
5fd75dd
test(cli): choose the real-nono fixture base by running nono, not by …
tps-flint Oct 16, 2026
857037c
test(cli): the real-nono fixture grants the checkout root, where node…
tps-flint Oct 16, 2026
22cebd1
fix(cli): ignore an inherited worktree marker and a failed cleanup pr…
tps-flint Oct 16, 2026
b7e3ec2
test(cli): end the real-nono control line so the denial marker is its…
tps-flint Oct 16, 2026
011d298
Merge remote-tracking branch 'origin/main' into fix/516-sandboxed-launch
tps-flint Oct 18, 2026
7a703fc
fix(cli): grant a new worktree only its git metadata, not the base re…
tps-flint Oct 18, 2026
cbcda23
docs(cli): qualify the worktree grant comments (cli#516)
tps-flint Oct 18, 2026
b38cf56
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
0cd9d40
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
5a5cccf
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
11c0c31
fix(cli): resolve the workspace agent in the nono gate and keep the w…
tps-flint Oct 18, 2026
be0888a
test(cli): say why the worktree fixture needs a usable key
tps-flint Oct 18, 2026
53ecc00
ci(nono): gate and firewall the profile-gate job's workspace install
tps-flint Oct 18, 2026
b28d2f4
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
03dc41f
test(cli#516): place the untracked-base fixture at a trusted base loc…
tps-flint Oct 18, 2026
9633652
fix(cli#516): the re-exec child opens the config file the grant names
tps-flint Oct 18, 2026
382d597
docs(cli#516): the child receives the config's canonical path
tps-flint Oct 18, 2026
0b35fa1
fix(cli#516): only a worktree the trusted base registers gets strict …
tps-flint Oct 18, 2026
3289fba
fix(cli#516): confine unregistered worktree launches and classify by …
tps-flint Oct 18, 2026
c41a2ce
test(cli#516): refuse a symlink marker before the base lookup
tps-flint Oct 18, 2026
a749691
fix(cli#516): refuse an absent registered worktree before computing g…
tps-flint Oct 18, 2026
3c86324
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
691025e
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
238c410
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
6f054e9
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
5fee766
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
df5f4b4
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
6844eab
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
5338b04
round: address the review verdict (Codex fix round)
tps-flint Oct 18, 2026
92be87a
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
26961df
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
7bbcc3e
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
1518776
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
c6a3888
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
f40a38e
round: address the review verdict (Codex fix round)
tps-flint Oct 19, 2026
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
3 changes: 3 additions & 0 deletions .changelog/unreleased/fixed-516-nono-gate-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **The nono profile gate resolves the workspace agent package.** The gate job
installs the workspace and builds the agent package before running, so its
launch-args checks use the local package rather than a published one.
9 changes: 9 additions & 0 deletions .changelog/unreleased/fixed-516-sandboxed-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **Sandboxed agent launch keeps `--config`, prepares the worktree with checkout deferred, and ignores flags inside a wrapped
payload.** For a selected runtime whose workspace has a base repository, `tps
agent start` reads `--config` once into a private snapshot used for parsing,
the attested re-exec and its read-file grant; when a base repository exists and the workspace is absent,
the parent creates the worktree with the checkout suppressed
and the hook path and fsmonitor disabled. Relative workspaces use the source config
pathname’s directory; the child uses the parent’s absolute workspace and refuses
a pending-worktree mismatch with `WorktreeLifecycleError` before checkout.
The sandbox-flag scan reads parsed arguments before a wrapped payload.
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,18 @@ jobs:
- uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2
with:
bun-version: "1.3.10"
# The gate imports this tree's launch-args helper, which imports the
# workspace agent package. Without the install and build, bun resolves a
# published @tpsdev-ai/agent instead of this tree's.
- name: Dependency release-age gate (cli#529)
run: node scripts/check-dep-ages.mjs --ci
- uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2
with:
mode: firewall-free
- name: Install workspace dependencies
run: sfw bun install --frozen-lockfile
- name: Build agent package
run: cd packages/agent && bun run build
- name: Resolve the pinned nono commit
id: pin
run: |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ services:
# (DEFAULT_NONO_PATHS in packages/cli/src/utils/launch-attestation.ts).
attested:
build: *test-build
command: node scripts/test-suite.mjs attested packages/cli/test/launch-attestation-real-nono.test.ts packages/cli/test/launch-attestation.test.ts packages/cli/test/runtime-attested-launch.test.ts
command: node scripts/test-suite.mjs attested packages/cli/test/launch-attestation-real-nono.test.ts packages/cli/test/launch-attestation.test.ts packages/cli/test/runtime-attested-launch.test.ts packages/cli/test/sandboxed-launch-516.test.ts
user: tps
working_dir: /app
environment:
Expand Down
105 changes: 59 additions & 46 deletions packages/cli/bin/tps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { requireLocalAgentId } from "../src/utils/local-agent.js";
import meow from "meow";
import { cliFlagDefinitions } from "../src/utils/cli-flags.js";
import { parseCliArgs } from "../src/utils/cli-args.js";
import { enforceLaunchControl, readLaunchFlags } from "../src/utils/nono.js";
import { enforceLaunchControl, ownFlag, readLaunchFlags, SANDBOXED_FLAG } 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 @@ -17,8 +17,13 @@ try {
process.exit(1);
}

const launchFlags = readLaunchFlags(process.argv);
if (launchFlags.refusal) enforceLaunchControl({ argv: process.argv });
/** Parsed CLI arguments, with `--` separating payload. */
function ownArgv(): string[] {
return [process.argv[0]!, process.argv[1]!, ...helpArgs.argv];
}

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

const cli = meow(
`
Expand Down Expand Up @@ -103,37 +108,28 @@ const [command, ...rest] = cli.input;
* an interactive TTY, and refuses a non-interactive agent launch that did not
* assert `--sandbox-required`. Implementation lives in src/utils/nono.ts.
*/
async function enforceLaunchControlOrExit(): Promise<void> {
const { enforceLaunchControl, isInteractiveTty, NO_SANDBOX_FLAG, SANDBOXED_FLAG } = await import(
async function enforceLaunchControlOrExit() {
const { enforceLaunchControl, isInteractiveTty, NO_SANDBOX_FLAG } = await import(
"../src/utils/nono.js"
);
// Under `--sandboxed` this process must HOLD THE LAUNCHER'S RELEASE before the
// gate may honour the flag (cli#350 round 4e). The handshake lives here, not
// in the gate: it is I/O (connect, report the pid, prove the canaries, wait —
// bounded — for `CONFINED <session> <this pid>`), while the gate stays pure.
// Nothing this process can observe about itself is proof; the launcher's view
// from OUTSIDE the sandbox is.
//
// cli#350 r4f — attest whenever a LAUNCHER launched this process, keyed on the
// locator (set by the launcher and only by it), NEVER on the TTY: the launcher
// requires the release on every launch, so a TTY child that skipped the
// handshake made every interactive `tps agent start` refuse with
// `no released child within …`. A human who types `--sandboxed` at a terminal
// carries no locator, so confinement stays undefined and rule 1b refuses it.
const { attestConfinement, LAUNCH_SOCK_ENV } = await import(
"../src/utils/launch-attestation.js"
);
let confinement: { released: boolean; reason?: string } | undefined;
if (process.argv.includes(SANDBOXED_FLAG)) {
const { attestConfinement, LAUNCH_SOCK_ENV } = await import(
"../src/utils/launch-attestation.js"
);
if (process.env[LAUNCH_SOCK_ENV]) {
const attestation = await attestConfinement();
confinement = { released: attestation.ok, reason: attestation.reason };
}
let worktreeBinding: import("../src/utils/agent-lifecycle.js").WorktreeLaunchBinding | undefined;
if (ownFlag(ownArgv(), SANDBOXED_FLAG) && process.env[LAUNCH_SOCK_ENV]) {
const attestation = await attestConfinement();
confinement = { released: attestation.ok, reason: attestation.reason };
worktreeBinding = attestation.worktreeBinding;
}
enforceLaunchControl({ command, rest, argv: process.argv, parsedFlags: cli.flags, confinement });
if (launchFlags.noSandbox && isInteractiveTty()) {
const sandboxed = confinement?.released === true;
enforceLaunchControl({ command, rest, argv: ownArgv(), parsedFlags: cli.flags,
confinement: { released: sandboxed, reason: confinement?.reason } });
const flags = readLaunchFlags(ownArgv(), cli.flags);
if (flags.noSandbox && isInteractiveTty()) {
console.warn(`⚠️ ${NO_SANDBOX_FLAG}: running WITHOUT nono isolation (interactive override).`);
}
return { ...flags, sandboxed, worktreeBinding: confinement?.released ? worktreeBinding : undefined };
}

// nono availability check (skip for --quiet-nono-check or office/mail relay commands)
Expand Down Expand Up @@ -276,7 +272,7 @@ async function main() {
return;
}

await enforceLaunchControlOrExit();
const launchDecision = await enforceLaunchControlOrExit();
await checkNono();
switch (command) {
case "init": {
Expand Down Expand Up @@ -480,14 +476,16 @@ 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.some((arg) => arg === "--runtime" || arg.startsWith("--runtime=")) ? cli.flags.runtime : undefined;
const payloadStart = helpArgs.argv.indexOf("--");
const ownArgs = payloadStart === -1 ? helpArgs.argv : helpArgs.argv.slice(0, payloadStart);
const runtimeArg = ownArgs.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;
const sandboxed = launchDecision.sandboxed;
const noSandbox = launchDecision.noSandbox;
// Selected runners execute after launcher release or an interactive
// TTY `--no-sandbox` opt-out.
if (attestedRuntime && (sandboxed || noSandbox)) {
Expand All @@ -498,11 +496,6 @@ async function main() {
const { load: parseYaml } = await import("js-yaml");
const cfgPath = configPath ?? join(homedir(), ".tps", "agents", agentId!, "agent.yaml");

/** Resolve the git worktree metadata dir for a workspace.
* A worktree's .git is a file containing "gitdir: /path/to/.git/worktrees/<name>".
* Codex workspace-write sandbox restricts writes to the workspace dir; the worktree
* metadata dir (index, HEAD, etc.) is outside that boundary and must be added to
* extraDirs so git commit/push can write index.lock. */
function resolveWorktreeGitDir(workspace: string): string | null {
try {
const dotGit = join(workspace, ".git");
Expand All @@ -514,7 +507,12 @@ async function main() {
return null;
}
const agentCfg = parseYaml(readFileSync(cfgPath, "utf-8")) as any;
const agentWorkspace = agentCfg.workspace ?? join(homedir(), "ops", "tps");
const { agentWorkspace: resolveWorkspace } = await import("../src/utils/agent-workspace.js");
const agentWorkspace = resolveWorkspace(
cfgPath,
agentCfg.workspace ?? join(homedir(), "ops", "tps"),
sandboxed ? process.env : undefined,
);

// OPS-47: Build workspace provider from agent.yaml config
const { GitWorkspaceProvider } = await import("../src/utils/workspace-provider.js");
Expand Down Expand Up @@ -544,12 +542,25 @@ async function main() {
}
}

const { onStart, onStop } = await import("../src/utils/agent-lifecycle.js");
const startResult = onStart(agentId!, agentWorkspace);
if (startResult.changed) console.log(`[${agentId}] worktree: ${startResult.reason}`);
// Complete this launch's deferred checkout before starting the runtime.
const { onStart, onStop, checkoutWorktree, worktreeLaunchBinding, worktreeLaunchGit } = await import("../src/utils/agent-lifecycle.js");
const pendingCheckout = process.env.TPS_WORKTREE_PENDING;
let worktreeCreated = false;
if (sandboxed && pendingCheckout !== undefined) {
const checkedOut = checkoutWorktree(agentWorkspace);
console.log(`[${agentId}] worktree: ${checkedOut.reason}`);
} else if (!sandboxed) {
const startResult = onStart(agentId!, agentWorkspace);
worktreeCreated = startResult.changed;
if (startResult.changed) console.log(`[${agentId}] worktree: ${startResult.reason}`);
}

try {
if (runtimeArg === "codex") {
const worktreeGit = worktreeLaunchGit(sandboxed
? launchDecision.worktreeBinding ?? { kind: "none" }
: worktreeLaunchBinding(agentWorkspace));

const { runCodexRuntime } = await import("../src/utils/codex-runtime.js");
const { parseReviewTriggerConfig } = await import("../src/utils/pr-review-trigger.js");
await runCodexRuntime({
Expand All @@ -561,7 +572,7 @@ async function main() {
join(homedir(), ".tps", "mail", agentId!),
join(homedir(), "ops", "tps"),
join(homedir(), "ops", "flair"),
...((worktreeGitDir) => worktreeGitDir ? [worktreeGitDir] : [])(resolveWorktreeGitDir(agentWorkspace)),
...(worktreeGit ? [worktreeGit.gitDir, worktreeGit.gitCommonDir] : []),
...(agentCfg.codex?.extraDirs ?? []),
],
taskTimeoutMs: agentCfg.taskTimeoutMs,
Expand Down Expand Up @@ -610,8 +621,10 @@ async function main() {
});
}
} finally {
const stopResult = onStop(agentId!, agentWorkspace);
if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`);
if (worktreeCreated) {
const stopResult = onStop(agentId!, agentWorkspace);
if (stopResult.changed) console.log(`[${agentId}] worktree removed: ${stopResult.reason}`);
}
}
} else {
await runAgent({
Expand All @@ -620,7 +633,7 @@ async function main() {
id: agentId,
sandbox: !noSandbox,
sandboxed,
sandboxRequired: launchFlags.sandboxRequired,
sandboxRequired: launchDecision.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,
Expand Down Expand Up @@ -1498,6 +1511,6 @@ async function main() {
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err?.message || err);
console.error(err?.name === "WorktreeLifecycleError" ? `${err.name}: ${err.message}` : err?.message || err);
process.exit(1);
});
Loading
Loading