tickmarkr 2.6.5 → 2.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/adapters/claude-code.js +8 -2
- package/dist/adapters/codex-commit-check.d.ts +16 -4
- package/dist/adapters/codex-commit-check.js +150 -26
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +20 -8
- package/dist/adapters/types.d.ts +4 -1
- package/dist/cli/commands/beat.d.ts +6 -1
- package/dist/cli/commands/beat.js +123 -76
- package/dist/cli/commands/doctor.js +3 -1
- package/dist/cli/commands/fleet.js +368 -85
- package/dist/cli/commands/init.js +23 -9
- package/dist/cli/commands/plan.js +12 -1
- package/dist/cli/commands/verify.js +37 -9
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +10 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/config/fleet-overlay.d.ts +14 -0
- package/dist/config/fleet-overlay.js +154 -25
- package/dist/drivers/orca.d.ts +17 -3
- package/dist/drivers/orca.js +64 -20
- package/dist/gates/baseline.d.ts +9 -0
- package/dist/gates/baseline.js +14 -3
- package/dist/gates/cache.js +1 -1
- package/dist/gates/review.js +15 -0
- package/dist/gates/run-gates.d.ts +20 -7
- package/dist/gates/run-gates.js +345 -210
- package/dist/gates/test-manifest.d.ts +11 -0
- package/dist/gates/test-manifest.js +151 -111
- package/dist/gates/timeout-shaped.d.ts +3 -0
- package/dist/gates/timeout-shaped.js +10 -0
- package/dist/run/beat-lifecycle.d.ts +154 -0
- package/dist/run/beat-lifecycle.js +825 -0
- package/dist/run/daemon.js +202 -126
- package/dist/run/git-trust.d.ts +27 -0
- package/dist/run/git-trust.js +178 -0
- package/dist/run/git.js +34 -14
- package/dist/run/journal.js +15 -3
- package/dist/run/lease.d.ts +23 -0
- package/dist/run/lease.js +232 -38
- package/dist/run/merge.js +2 -2
- package/dist/run/stall.js +20 -1
- package/dist/run/supervision.d.ts +8 -0
- package/dist/run/supervision.js +24 -4
- package/dist/run/verification-job.d.ts +51 -0
- package/dist/run/verification-job.js +137 -0
- package/dist/tui/ink/fleet-app.d.ts +35 -5
- package/dist/tui/ink/fleet-app.js +244 -124
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +19 -10
- package/skills/tickmarkr-loop/SKILL.md +19 -10
- package/skills/tickmarkr-overseer/SKILL.md +25 -16
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
- package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
package/README.md
CHANGED
|
@@ -258,9 +258,13 @@ two-pane surface: the left rail lists views (**All models**, **Shapes**, **Steer
|
|
|
258
258
|
installed agent CLI with its auth state and model count; the right pane is a searchable model list
|
|
259
259
|
with tier, context, price, and probe-latency columns. `Space` allows/denies, `Enter` classifies an
|
|
260
260
|
unclassified model (with a required benchmark-provenance note) or pins a classified one to a shape,
|
|
261
|
-
`m` opens the routing-mode presets, and `w` renders the unified diff of
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
`m` opens the routing-mode presets, and `w` renders the unified diff of the one overlay the save
|
|
262
|
+
lands in, named in the review: an allow/deny membership edit goes to the layer that holds it (the
|
|
263
|
+
repo overlay when it declares that family, else your user overlay), everything else to the user
|
|
264
|
+
overlay; a batch needing both is refused — save each half in its own session. Nothing is written
|
|
265
|
+
until you confirm with `y`. A worker-only deny on a partially excluded adapter takes two rail
|
|
266
|
+
presses: out · all seats, then out · workers. The Shapes view pins from a candidate picker ranked by
|
|
267
|
+
the production router.
|
|
264
268
|
|
|
265
269
|
Routing-mode semantics, pin/floor/prefer precedence, review and consult steering syntax,
|
|
266
270
|
provenance rules, and `--quality` / `--mode` flags are documented in
|
|
@@ -216,6 +216,7 @@ export function probeVersion(bin) {
|
|
|
216
216
|
// OBS-1182: an absent effort renders nothing, so the CLI keeps its own default. Every builder places
|
|
217
217
|
// it right after --model's value and before another flag, never before the prompt positional.
|
|
218
218
|
const claudeEffortFlag = (effort) => (effort ? ` --effort ${shq(effort)}` : "");
|
|
219
|
+
const WORKER_SETTINGS = `'{"promptSuggestionEnabled":false,"remoteControlAtStartup":false}'`;
|
|
219
220
|
export const claudeCode = {
|
|
220
221
|
id: "claude-code",
|
|
221
222
|
vendor: "anthropic",
|
|
@@ -253,17 +254,22 @@ export const claudeCode = {
|
|
|
253
254
|
// live check ate the prompt), and --prompt-suggestions takes an OPTIONAL value — appended directly
|
|
254
255
|
// before the prompt it would swallow it the same way. So the setting's value is always followed by
|
|
255
256
|
// another flag, never by the prompt positional.
|
|
257
|
+
// Remote Control is OFF for workers: a user's remoteControlAtStartup otherwise registers every pane
|
|
258
|
+
// worker as a Remote Control session and a local peer, an input channel tickmarkr never records (607 of
|
|
259
|
+
// 607 interactive workers on one host, 2026-10-05). --settings outranks the user setting (live-checked
|
|
260
|
+
// against claude 2.1.289: the banner's /rc marker is gone with the key, present without it). The -p form
|
|
261
|
+
// never registers.
|
|
256
262
|
// OBS-931: the same ONE-argv-string hazard as codex (OBS-930) — over promptArgvCeiling() the TUI
|
|
257
263
|
// launch would E2BIG on Linux, so it returns null → worker-mode-fallback → the headless form.
|
|
258
264
|
// resumeCommand keeps the shape: its contract returns a string (composer delivery is 2.4.3 work).
|
|
259
265
|
interactiveCommand: (promptFile, model, effort) => promptFitsArgv(promptFile)
|
|
260
|
-
? `claude --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings
|
|
266
|
+
? `claude --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings ${WORKER_SETTINGS} --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`
|
|
261
267
|
: null,
|
|
262
268
|
trustDialog: CLAUDE_TRUST_DIALOG,
|
|
263
269
|
inputBox: CLAUDE_INPUT_BOX,
|
|
264
270
|
// A resumed attempt lands in the same painted editor, so it carries the same ghost-text suppression
|
|
265
271
|
// and the same value-then-flag placement.
|
|
266
|
-
resumeCommand: (sessionId, promptFile, model, effort) => `claude -r ${shq(sessionId)} --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings
|
|
272
|
+
resumeCommand: (sessionId, promptFile, model, effort) => `claude -r ${shq(sessionId)} --model ${shq(model)}${claudeEffortFlag(effort)} --strict-mcp-config --mcp-config '{"mcpServers":{}}' --settings ${WORKER_SETTINGS} --prompt-suggestions false --permission-mode bypassPermissions "$(cat ${shq(promptFile)})"`,
|
|
267
273
|
invoke(task, _cwd, a, ctx) {
|
|
268
274
|
return { command: this.headlessCommand(ctx.promptFile, a.model, a.effort) };
|
|
269
275
|
},
|
|
@@ -1,25 +1,37 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export type CodexCommitStatus
|
|
1
|
+
import { type AuthHealth, type CodexCommitStatus, type WorkerAdapter } from "./types.js";
|
|
2
|
+
export type { CodexCommitStatus };
|
|
3
3
|
export interface CodexCommitProbe {
|
|
4
4
|
worktree: string;
|
|
5
5
|
commonDir: string;
|
|
6
|
+
branch: string;
|
|
6
7
|
control: string;
|
|
7
8
|
lock: string;
|
|
9
|
+
hostileHook: string;
|
|
10
|
+
hostileRoot: string;
|
|
8
11
|
token: string;
|
|
9
12
|
signal?: AbortSignal;
|
|
10
13
|
}
|
|
11
|
-
/** Runs the
|
|
14
|
+
/** Runs the probe script inside the Codex sandbox and resolves its stdout. Injected in tests. */
|
|
12
15
|
export type CodexSandbox = (probe: CodexCommitProbe) => Promise<string>;
|
|
13
16
|
export interface CodexCommitResult {
|
|
14
17
|
status: CodexCommitStatus;
|
|
15
18
|
detail: string;
|
|
16
19
|
}
|
|
17
|
-
|
|
20
|
+
/** The `sh` argv production runs at the fixture worktree: `codex sandbox` under the worker grant fragment. */
|
|
21
|
+
export declare const codexSandboxArgs: (p: CodexCommitProbe, grant?: string) => string[];
|
|
22
|
+
/** A sandbox executing `grant`; production is CODEX_GIT_GRANT. Tests pass a mutant grant to prove discrimination. */
|
|
23
|
+
export declare const codexSandboxFor: (grant: string) => CodexSandbox;
|
|
18
24
|
export declare const codexSandbox: CodexSandbox;
|
|
19
25
|
export declare function probeCodexCommit(repoRoot: string, sandbox?: CodexSandbox, signal?: AbortSignal): Promise<CodexCommitResult>;
|
|
26
|
+
/** A persisted verdict outside the closed table (an older or newer doctor.json) reads unknown, never allowed. */
|
|
27
|
+
export declare const codexCommitStatus: (health: Record<string, AuthHealth>) => CodexCommitStatus;
|
|
20
28
|
/** Doctor's one call: gated on the REAL codex adapter object (a codex-id stub gets no probe and no output
|
|
21
29
|
* unless a test injects a sandbox — the kimiTurnEnabled rule). Mutates only the health record doctor owns. */
|
|
22
30
|
export declare function recordCodexCommit(adapters: WorkerAdapter[], health: Record<string, AuthHealth>, cwd: string, sandbox?: CodexSandbox): Promise<void>;
|
|
23
31
|
/** Plan's headline beneath a Codex worker row; null for every other worker and for an allowed probe.
|
|
24
32
|
* An absent field is unknown, but only the real adapter earns the line — stubs stay byte-identical. */
|
|
25
33
|
export declare function codexCommitHeadline(worker: string, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): string | null;
|
|
34
|
+
/** Plan's one line for Codex's NON-worker roles (judge, review, consult). v2.6.7 T4: those seats launch
|
|
35
|
+
* grantless, but they run in the same repository whose shared metadata a Codex worker's grant let the sandbox
|
|
36
|
+
* write — so an escape still blocks; allowed, protected and unknown concern commits, which they never make. */
|
|
37
|
+
export declare function codexEscapeRoleLine(roles: string[], adapters: WorkerAdapter[], health: Record<string, AuthHealth>): string | null;
|
|
@@ -4,20 +4,44 @@ import { existsSync, mkdtempSync, readFileSync, rmdirSync, rmSync } from "node:f
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { basename, dirname, join } from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
-
import {
|
|
7
|
+
import { assertGitTrust, GitTrustRefusal, protectedGitEnv } from "../run/git-trust.js";
|
|
8
|
+
import { CODEX_GIT_GRANT, codex } from "./codex.js";
|
|
9
|
+
import { shq } from "./types.js";
|
|
8
10
|
const exec = promisify(execFile);
|
|
9
11
|
const git = async (cwd, ...args) => (await exec("git", args, { cwd })).stdout.trim();
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
// v2.6.7 T4: host git after the sandbox has had the fixture — validation and cleanup — runs the uncached trust
|
|
13
|
+
// check and the forced child config on every call, so seat-rewritten metadata can neither run a config program
|
|
14
|
+
// nor answer for the probe's commit.
|
|
15
|
+
const hostGit = async (cwd, ...args) => {
|
|
16
|
+
assertGitTrust(cwd);
|
|
17
|
+
return (await exec("git", args, { cwd, env: protectedGitEnv(process.env) })).stdout.trim();
|
|
18
|
+
};
|
|
19
|
+
/** The fixture checkout's trust after the sandbox returned or threw: a refusal is reported with its path. */
|
|
20
|
+
const fixtureRefusal = (worktree) => {
|
|
21
|
+
try {
|
|
22
|
+
assertGitTrust(worktree);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return unknown(e instanceof GitTrustRefusal ? `the sandbox left untrusted fixture git metadata (${e.message})` : `fixture git trust unreadable (${String(e)})`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const CONTROL = "tickmarkr-codex-commit-control";
|
|
30
|
+
// $1 control, $2 token, $3 hostile hooks member, $4 hostile common-root member. The commit disables hooks (the
|
|
31
|
+
// probe asks what the sandbox permits, never what a user hook decides) and forces the reflog so common/logs is
|
|
32
|
+
// always exercised. noclobber (set -C) never writes over a foreign occupant of a hostile path.
|
|
33
|
+
const SCRIPT = `printf %s "$2" > "$1" && echo control=ok || echo control=fail
|
|
34
|
+
git add -- "$1" && git -c core.hooksPath=/dev/null -c core.logAllRefUpdates=true -c commit.gpgsign=false -c user.name=tickmarkr -c user.email=probe@tickmarkr.invalid commit -q -m "tickmarkr-probe $2" && echo commit=ok || echo commit=fail
|
|
35
|
+
(set -C; printf %s "$2" > "$3") 2>/dev/null && echo hook=written || echo hook=denied
|
|
36
|
+
(set -C; printf %s "$2" > "$4") 2>/dev/null && echo root=written || echo root=denied`;
|
|
37
|
+
/** The `sh` argv production runs at the fixture worktree: `codex sandbox` under the worker grant fragment. */
|
|
38
|
+
export const codexSandboxArgs = (p, grant = CODEX_GIT_GRANT) => [
|
|
39
|
+
"-c",
|
|
40
|
+
`codex sandbox -c 'sandbox_mode="workspace-write"' ${grant} -- sh -c ${shq(SCRIPT)} sh ${[p.control, p.token, p.hostileHook, p.hostileRoot].map(shq).join(" ")}`,
|
|
19
41
|
];
|
|
20
|
-
|
|
42
|
+
/** A sandbox executing `grant`; production is CODEX_GIT_GRANT. Tests pass a mutant grant to prove discrimination. */
|
|
43
|
+
export const codexSandboxFor = (grant) => async (p) => (await exec("sh", codexSandboxArgs(p, grant), { cwd: p.worktree, timeout: 20_000, signal: p.signal })).stdout;
|
|
44
|
+
export const codexSandbox = codexSandboxFor(CODEX_GIT_GRANT);
|
|
21
45
|
const holds = (path, token) => {
|
|
22
46
|
try {
|
|
23
47
|
return readFileSync(path, "utf8") === token;
|
|
@@ -27,38 +51,106 @@ const holds = (path, token) => {
|
|
|
27
51
|
}
|
|
28
52
|
};
|
|
29
53
|
const unknown = (detail) => ({ status: "unknown", detail });
|
|
54
|
+
// The bytes on disk decide an escape whatever the receipts say — and whether or not the sandbox exited cleanly.
|
|
55
|
+
const escaped = (hostile, token) => {
|
|
56
|
+
const written = hostile.filter((m) => holds(m, token));
|
|
57
|
+
return written.length ? { status: "escape", detail: `the sandbox wrote ${written.join(" and ")} outside the worker grant` } : undefined;
|
|
58
|
+
};
|
|
59
|
+
// One outcome per receipt field: a field reporting two outcomes (commit=ok AND commit=fail) is contradictory.
|
|
60
|
+
const contradictory = (out) => {
|
|
61
|
+
const seen = new Map();
|
|
62
|
+
for (const [, field, outcome] of out.matchAll(/^(control|commit|hook|root)=(\S+)/gm)) {
|
|
63
|
+
if ((seen.get(field) ?? outcome) !== outcome)
|
|
64
|
+
return true;
|
|
65
|
+
seen.set(field, outcome);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
const tipOf = async (repo, ref) => hostGit(repo, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`).catch(() => undefined);
|
|
70
|
+
// This probe's commit and nothing else: one parent (the fixture base), its token in the subject and the control.
|
|
71
|
+
const isProbeCommit = async (repo, commit, base, token) => {
|
|
72
|
+
try {
|
|
73
|
+
return await hostGit(repo, "log", "-1", "--format=%P %s", commit) === `${base} tickmarkr-probe ${token}`
|
|
74
|
+
&& await hostGit(repo, "cat-file", "blob", `${commit}:${CONTROL}`) === token;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
30
80
|
export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal) {
|
|
31
81
|
const token = randomUUID();
|
|
32
82
|
const parent = mkdtempSync(join(tmpdir(), "tickmarkr-codex-commit-"));
|
|
33
83
|
// git names the fixture's metadata directory after the checkout's basename; a unique one makes that
|
|
34
84
|
// directory — and so the lock path — known BEFORE `worktree add`, which can fail after creating it
|
|
35
|
-
const
|
|
85
|
+
const branch = `tickmarkr-probe-${token}`;
|
|
86
|
+
const ref = `refs/heads/${branch}`;
|
|
87
|
+
const worktree = join(parent, branch);
|
|
36
88
|
let lock;
|
|
89
|
+
let base;
|
|
90
|
+
let hostile = [];
|
|
91
|
+
let sandboxed = false;
|
|
37
92
|
try {
|
|
38
93
|
const commonDir = await git(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
39
94
|
lock = join(commonDir, "worktrees", basename(worktree), "index.lock");
|
|
40
|
-
|
|
95
|
+
const hostileHook = join(commonDir, "hooks", branch), hostileRoot = join(commonDir, branch);
|
|
96
|
+
hostile = [hostileHook, hostileRoot];
|
|
97
|
+
if (hostile.some((m) => existsSync(m)))
|
|
98
|
+
return unknown("a foreign file already holds a hostile probe path");
|
|
99
|
+
base = await git(repoRoot, "rev-parse", "--verify", "HEAD^{commit}");
|
|
100
|
+
await git(repoRoot, "worktree", "add", "-b", branch, worktree, base);
|
|
41
101
|
if (join(await git(worktree, "rev-parse", "--absolute-git-dir"), "index.lock") !== lock)
|
|
42
102
|
return unknown("the fixture worktree metadata is not where git was expected to put it");
|
|
43
|
-
const control = join(worktree,
|
|
103
|
+
const control = join(worktree, CONTROL);
|
|
44
104
|
if (existsSync(lock))
|
|
45
105
|
return unknown("a foreign index.lock already holds the fixture worktree");
|
|
46
|
-
|
|
47
|
-
|
|
106
|
+
sandboxed = true;
|
|
107
|
+
const out = await sandbox({ worktree, commonDir, branch, control, lock, hostileHook, hostileRoot, token, signal });
|
|
108
|
+
// An escape on disk dominates; every other verdict needs consistent receipts AND disk — and trusted metadata.
|
|
109
|
+
const escape = escaped(hostile, token);
|
|
110
|
+
if (escape)
|
|
111
|
+
return escape;
|
|
112
|
+
const refused = fixtureRefusal(worktree);
|
|
113
|
+
if (refused)
|
|
114
|
+
return refused;
|
|
115
|
+
if (contradictory(out))
|
|
116
|
+
return unknown("the sandbox receipts contradict each other");
|
|
48
117
|
if (!/^control=ok$/m.test(out) || !holds(control, token))
|
|
49
118
|
return unknown("the sandbox did not write the ordinary control file");
|
|
50
|
-
if (
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
|
|
119
|
+
if (!/^hook=denied$/m.test(out) || !/^root=denied$/m.test(out) || hostile.some((m) => existsSync(m))) {
|
|
120
|
+
return unknown("the sandbox result for the shared-metadata members was unreadable");
|
|
121
|
+
}
|
|
122
|
+
const tip = await tipOf(repoRoot, ref);
|
|
123
|
+
if (/^commit=ok$/m.test(out) && tip && tip !== base && await isProbeCommit(repoRoot, tip, base, token)) {
|
|
124
|
+
let reflog = "";
|
|
125
|
+
try {
|
|
126
|
+
reflog = readFileSync(join(commonDir, "logs", ref), "utf8");
|
|
127
|
+
}
|
|
128
|
+
catch { /* absent: unknown below */ }
|
|
129
|
+
if (reflog.trimEnd().split("\n").pop()?.startsWith(`${base} ${tip} `)) {
|
|
130
|
+
return { status: "allowed", detail: "the sandbox committed on its fixture branch and was denied both shared-metadata members" };
|
|
131
|
+
}
|
|
132
|
+
return unknown("the fixture branch moved but its reflog does not record the probe commit");
|
|
133
|
+
}
|
|
134
|
+
if (/^commit=fail$/m.test(out) && tip === base) {
|
|
135
|
+
return { status: "protected", detail: "the sandbox wrote an ordinary worktree file but denied the linked-worktree commit" };
|
|
54
136
|
}
|
|
55
|
-
return unknown("the sandbox
|
|
137
|
+
return unknown("the fixture branch does not match the sandbox commit report (a foreign change may have moved it)");
|
|
56
138
|
}
|
|
57
139
|
catch (e) {
|
|
140
|
+
// a sandbox that wrote a hostile member and then failed (or was cancelled) still escaped: inspect before cleanup
|
|
141
|
+
const escape = escaped(hostile, token);
|
|
142
|
+
if (escape)
|
|
143
|
+
return escape;
|
|
144
|
+
const refused = sandboxed ? fixtureRefusal(worktree) : undefined;
|
|
145
|
+
if (refused)
|
|
146
|
+
return refused;
|
|
58
147
|
return unknown(signal?.aborted ? "probe cancelled" : `probe did not run (${(e instanceof Error ? e.message : String(e)).split("\n")[0]})`);
|
|
59
148
|
}
|
|
60
149
|
finally {
|
|
61
|
-
// only
|
|
150
|
+
// only files holding this probe's token are ours to remove; a foreign occupant is never touched
|
|
151
|
+
for (const m of hostile)
|
|
152
|
+
if (holds(m, token))
|
|
153
|
+
rmSync(m, { force: true });
|
|
62
154
|
if (lock && holds(lock, token))
|
|
63
155
|
rmSync(lock, { force: true });
|
|
64
156
|
// a foreign lock lives in the fixture's metadata directory, which `worktree remove` would delete with
|
|
@@ -66,7 +158,7 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
|
|
|
66
158
|
// prune — that would take an earlier probe's preserved foreign lock (or anyone's prunable worktree).
|
|
67
159
|
const foreign = lock !== undefined && existsSync(lock);
|
|
68
160
|
if (lock && !foreign) {
|
|
69
|
-
await
|
|
161
|
+
await hostGit(repoRoot, "worktree", "remove", "--force", worktree).catch(() => undefined);
|
|
70
162
|
// a failed `worktree add` leaves metadata git no longer removes by path: drop this probe's own directory
|
|
71
163
|
rmSync(dirname(lock), { recursive: true, force: true });
|
|
72
164
|
try {
|
|
@@ -74,14 +166,34 @@ export async function probeCodexCommit(repoRoot, sandbox = codexSandbox, signal)
|
|
|
74
166
|
}
|
|
75
167
|
catch { /* other worktrees live there */ }
|
|
76
168
|
}
|
|
169
|
+
// The fixture branch and its reflog go only while the tip is still this probe's (the base or its own
|
|
170
|
+
// commit), and update-ref's old-value check refuses if anyone moves it between this read and the delete.
|
|
171
|
+
// Metadata preserved for a foreign lock keeps its branch too: that checkout's HEAD still names it.
|
|
172
|
+
const tip = base === undefined || foreign ? undefined : await tipOf(repoRoot, ref);
|
|
173
|
+
if (tip && base && (tip === base || await isProbeCommit(repoRoot, tip, base, token))) {
|
|
174
|
+
await hostGit(repoRoot, "update-ref", "-d", ref, tip).catch(() => undefined);
|
|
175
|
+
}
|
|
77
176
|
rmSync(parent, { recursive: true, force: true });
|
|
78
177
|
}
|
|
79
178
|
}
|
|
80
179
|
const NOTE = {
|
|
81
180
|
protected: "linked-worktree git metadata protected",
|
|
82
|
-
allowed: "linked-worktree commit probe:
|
|
181
|
+
allowed: "linked-worktree commit probe: commit allowed, shared hooks/config denied",
|
|
182
|
+
escape: "BLOCKING security warning: codex sandbox escape into shared git metadata",
|
|
83
183
|
unknown: "linked-worktree commit probe unknown",
|
|
84
184
|
};
|
|
185
|
+
const CONSEQUENCE = {
|
|
186
|
+
protected: "; a Codex worker cannot commit in its worktree",
|
|
187
|
+
allowed: "",
|
|
188
|
+
escape: "; every Codex worker (headless and interactive) launches with this grant, while judge, review and consult seats launch grantless — stop Codex seats and inspect .git/hooks and git config --list --show-origin (core.hooksPath, core.fsmonitor, filter.*, diff.external)",
|
|
189
|
+
unknown: "",
|
|
190
|
+
};
|
|
191
|
+
const STATUSES = new Set(Object.keys(NOTE));
|
|
192
|
+
/** A persisted verdict outside the closed table (an older or newer doctor.json) reads unknown, never allowed. */
|
|
193
|
+
export const codexCommitStatus = (health) => {
|
|
194
|
+
const s = health[codex.id]?.codexCommit;
|
|
195
|
+
return s !== undefined && STATUSES.has(s) ? s : "unknown";
|
|
196
|
+
};
|
|
85
197
|
/** Doctor's one call: gated on the REAL codex adapter object (a codex-id stub gets no probe and no output
|
|
86
198
|
* unless a test injects a sandbox — the kimiTurnEnabled rule). Mutates only the health record doctor owns. */
|
|
87
199
|
export async function recordCodexCommit(adapters, health, cwd, sandbox) {
|
|
@@ -90,15 +202,19 @@ export async function recordCodexCommit(adapters, health, cwd, sandbox) {
|
|
|
90
202
|
if (!adapter || (adapter !== codex && !sandbox) || !h?.installed)
|
|
91
203
|
return;
|
|
92
204
|
const r = await probeCodexCommit(cwd, sandbox);
|
|
93
|
-
const say = `${NOTE[r.status]} — ${r.detail}${r.status
|
|
205
|
+
const say = `${NOTE[r.status]} — ${r.detail}${CONSEQUENCE[r.status]}`;
|
|
94
206
|
health[codex.id] = { ...h, codexCommit: r.status, note: `${h.note ? `${h.note}; ` : ""}${say}` };
|
|
95
207
|
}
|
|
208
|
+
const ESCAPE_ACTION = "stop Codex seats and inspect .git/hooks and git config --list --show-origin (core.hooksPath, core.fsmonitor, filter.*, diff.external) before dispatch";
|
|
96
209
|
/** Plan's headline beneath a Codex worker row; null for every other worker and for an allowed probe.
|
|
97
210
|
* An absent field is unknown, but only the real adapter earns the line — stubs stay byte-identical. */
|
|
98
211
|
export function codexCommitHeadline(worker, adapters, health) {
|
|
99
212
|
if (worker !== codex.id || !adapters.includes(codex))
|
|
100
213
|
return null;
|
|
101
|
-
const status = health
|
|
214
|
+
const status = codexCommitStatus(health);
|
|
215
|
+
if (status === "escape") {
|
|
216
|
+
return ` BLOCKING security warning: codex sandbox escape (doctor probe) — this worker's launch grant let the sandbox write shared git metadata; ${ESCAPE_ACTION}`;
|
|
217
|
+
}
|
|
102
218
|
if (status === "protected") {
|
|
103
219
|
return " worker restriction: codex linked-worktree git metadata is protected (doctor probe) — this worker cannot commit its own work; routing consequence: steer this task to an eligible non-Codex worker";
|
|
104
220
|
}
|
|
@@ -106,3 +222,11 @@ export function codexCommitHeadline(worker, adapters, health) {
|
|
|
106
222
|
? " worker restriction: unknown — the codex linked-worktree commit probe has no verdict; run tickmarkr doctor"
|
|
107
223
|
: null;
|
|
108
224
|
}
|
|
225
|
+
/** Plan's one line for Codex's NON-worker roles (judge, review, consult). v2.6.7 T4: those seats launch
|
|
226
|
+
* grantless, but they run in the same repository whose shared metadata a Codex worker's grant let the sandbox
|
|
227
|
+
* write — so an escape still blocks; allowed, protected and unknown concern commits, which they never make. */
|
|
228
|
+
export function codexEscapeRoleLine(roles, adapters, health) {
|
|
229
|
+
if (!roles.length || !adapters.includes(codex) || codexCommitStatus(health) !== "escape")
|
|
230
|
+
return null;
|
|
231
|
+
return `BLOCKING security warning: codex sandbox escape (doctor probe) — Codex ${roles.join("/")} seats launch grantless, but the Codex worker grant let the sandbox write shared git metadata they run against; ${ESCAPE_ACTION}`;
|
|
232
|
+
}
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare function readCodexModelsCache(path?: string): {
|
|
|
4
4
|
fetchedAt?: string;
|
|
5
5
|
retirements?: Record<string, ModelRetirement | null>;
|
|
6
6
|
};
|
|
7
|
+
export declare const CODEX_GIT_GRANT = "-c \"sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ \"$tkr_gitdir\" != \"$tkr_common\" ]; then printf '\"%s\",' \"$tkr_gitdir\"; fi; printf '\"%s/objects\",\"%s/refs\",\"%s/logs\"' \"$tkr_common\" \"$tkr_common\" \"$tkr_common\")]\"";
|
|
7
8
|
export declare const CODEX_TRUST_DIALOG: TrustDialog;
|
|
8
9
|
export declare const CODEX_INPUT_BOX: import("./types.js").InputBox;
|
|
9
10
|
export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -96,9 +96,17 @@ function codexRetirement(upgrade) {
|
|
|
96
96
|
}
|
|
97
97
|
// tickmarkr worktrees keep their gitdir under the MAIN repo's .git/worktrees/<name> — outside the
|
|
98
98
|
// workspace-write sandbox root — so git commit dies on index.lock (incident run-20260709-104447
|
|
99
|
-
// P87-08: "Unable to create …/.git/worktrees/…/index.lock"). Expanded by the
|
|
100
|
-
// worktree cwd
|
|
101
|
-
|
|
99
|
+
// P87-08: "Unable to create …/.git/worktrees/…/index.lock"). Expanded by the launching shell at the
|
|
100
|
+
// worktree cwd, for Codex WORKERS only: v2.6.7 T4 (D-1046) — invoke() (the headless worker form, including
|
|
101
|
+
// the interactive size fallback) and interactiveCommand carry it; headlessCommand, which judge, review,
|
|
102
|
+
// consult and the model probe launch, carries no writable_roots at all (those seats never commit).
|
|
103
|
+
// v2.6.6 T9 (K, D-912): the grant is exactly what a linked-worktree branch commit writes — the worktree's
|
|
104
|
+
// own gitdir (only when it differs from the common dir), common/objects, common/refs, common/logs. The
|
|
105
|
+
// shipped [common] grant (and D-910's [common, gitdir]) let a sandboxed seat write <common>/hooks and
|
|
106
|
+
// <common>/config, which unsandboxed git later runs. No common-root fallback: an ordinary checkout gets
|
|
107
|
+
// no gitdir root and its commit fails closed. packed-refs is not granted (packed-ref ops fail closed).
|
|
108
|
+
// doctor's probe (codex-commit-check.ts) executes these exact bytes; it never rebuilds the grant.
|
|
109
|
+
export const CODEX_GIT_GRANT = `-c "sandbox_workspace_write.writable_roots=[$(tkr_common=$(git rev-parse --path-format=absolute --git-common-dir); tkr_gitdir=$(git rev-parse --absolute-git-dir); if [ "$tkr_gitdir" != "$tkr_common" ]; then printf '"%s",' "$tkr_gitdir"; fi; printf '"%s/objects","%s/refs","%s/logs"' "$tkr_common" "$tkr_common" "$tkr_common")]"`;
|
|
102
110
|
// OBS-125: codex 0.144.x gates enabled hooks behind PER-PROJECT-PATH hook trust — a NEW gate distinct
|
|
103
111
|
// from the directory trust seedCodexTrust seeds. Every worker runs in a fresh ephemeral worktree, an
|
|
104
112
|
// untrusted project, so codex re-prompts "Hooks need review" and the interactive worker stalls forever
|
|
@@ -248,6 +256,8 @@ export function codexMcpSuppressionFlags(configPath) {
|
|
|
248
256
|
}
|
|
249
257
|
return flags.join(" ");
|
|
250
258
|
}
|
|
259
|
+
// The one headless form: `grant` is "" for every non-worker seat and ` ${CODEX_GIT_GRANT}` for the worker.
|
|
260
|
+
const codexExec = (promptFile, model, effort, grant) => `codex exec --sandbox workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()}${grant}${codexEffortFlag(effort)} --model ${shq(model)} - < ${shq(promptFile)}`;
|
|
251
261
|
export const codex = {
|
|
252
262
|
id: "codex",
|
|
253
263
|
vendor: "openai",
|
|
@@ -257,27 +267,29 @@ export const codex = {
|
|
|
257
267
|
probe: async () => probeVersion("codex"),
|
|
258
268
|
channels: (cfg) => channelsFromConfig("codex", cfg),
|
|
259
269
|
// v1.65 T3: every flag the command builder below hardcodes (incl. codexMcpSuppressionFlags' -c/
|
|
260
|
-
// --disable and
|
|
270
|
+
// --disable and CODEX_GIT_GRANT's -c) — all listed by top-level `codex --help`, verified 2026-07-22.
|
|
261
271
|
hardcodedFlags: { binary: "codex", flags: ["--sandbox", "-a", "-s", "--model", "-c", "--disable", "--dangerously-bypass-hook-trust"] },
|
|
262
272
|
// --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
|
|
263
273
|
// MCP suppression built per dispatch (config can change between runs) — see codexMcpSuppressionFlags.
|
|
264
274
|
// CODEX_HOOK_TRUST (OBS-125) clears the per-worktree "Hooks need review" gate while keeping the sandbox.
|
|
265
|
-
|
|
275
|
+
// v2.6.7 T4 (D-1046): grantless — judge, review, consult and probe-model seats read the checkout and
|
|
276
|
+
// never commit, so they get no writable git metadata. Workers launch through invoke() below.
|
|
277
|
+
headlessCommand: (promptFile, model, effort) => codexExec(promptFile, model, effort, ""),
|
|
266
278
|
// OBS-930: the visible pane runs the REAL TUI. Codex's TUI takes its prompt only as the [PROMPT]
|
|
267
279
|
// positional (`codex --help`, 0.153.4 — no file/stdin form), so the launch inlines the file exactly
|
|
268
280
|
// as the claude adapter does: the prompt is the LAST positional and every flag value is followed by
|
|
269
281
|
// a flag, never by the prompt. The argv hazard that once forbade this (OBS-889: `countLiveSuites`
|
|
270
282
|
// matched a suite word 140 KB into a finished worker's argv) is closed on the counter side — the
|
|
271
283
|
// census reads a command's first four tokens only — and those four never carry a suite word here.
|
|
272
|
-
// Same sandbox, hook trust
|
|
284
|
+
// Same sandbox, hook trust, MCP suppression and worker grant as invoke()'s headless worker form; `-a never` is the TUI's
|
|
273
285
|
// autonomous approval policy (exec has no approvals to configure).
|
|
274
286
|
// OBS-930 (Linux): the inlined prompt is ONE argv string and Linux caps one at 131072 bytes, so a
|
|
275
287
|
// prompt over promptArgvCeiling() returns null → worker-mode-fallback → the headless form (types.ts).
|
|
276
288
|
interactiveCommand: (promptFile, model, effort) => promptFitsArgv(promptFile)
|
|
277
|
-
? `codex -a never -s workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${
|
|
289
|
+
? `codex -a never -s workspace-write ${CODEX_HOOK_TRUST} ${codexMcpSuppressionFlags()} ${CODEX_GIT_GRANT}${codexEffortFlag(effort)} --model ${shq(model)} "$(cat ${shq(promptFile)})"`
|
|
278
290
|
: null,
|
|
279
291
|
invoke(task, _cwd, a, ctx) {
|
|
280
|
-
return { command:
|
|
292
|
+
return { command: codexExec(ctx.promptFile, a.model, a.effort, ` ${CODEX_GIT_GRANT}`) };
|
|
281
293
|
},
|
|
282
294
|
parse: parseWorkerResult,
|
|
283
295
|
// v1.22 T5: seed [projects."<repoRoot>"] trust_level="trusted" so fresh worktrees never stall on
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface AuthHealth {
|
|
|
51
51
|
modelAuth?: Record<string, ModelAuth>;
|
|
52
52
|
modelIdentities?: Record<string, string>;
|
|
53
53
|
modelRetirements?: Record<string, ModelRetirement | null>;
|
|
54
|
-
codexCommit?:
|
|
54
|
+
codexCommit?: CodexCommitStatus;
|
|
55
55
|
launchServices?: LaunchServicesRecord;
|
|
56
56
|
}
|
|
57
57
|
export interface LaunchServicesRecord {
|
|
@@ -59,6 +59,9 @@ export interface LaunchServicesRecord {
|
|
|
59
59
|
start: string;
|
|
60
60
|
advisory?: string;
|
|
61
61
|
}
|
|
62
|
+
/** allowed: linked branch commit landed, shared hooks/common root denied · protected: commit denied, controls
|
|
63
|
+
* readable · escape: a unique hostile member was written · unknown: unreadable or contradictory receipts. */
|
|
64
|
+
export type CodexCommitStatus = "allowed" | "protected" | "escape" | "unknown";
|
|
62
65
|
export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
|
|
63
66
|
export interface Invocation {
|
|
64
67
|
command: string;
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import { type SupervisionTier } from "../../run/supervision.js";
|
|
2
|
-
|
|
2
|
+
import { type BeatLifecycleDeps } from "../../run/beat-lifecycle.js";
|
|
3
|
+
export declare function beat(argv: string[], cwd?: string, deps?: BeatLifecycleDeps): Promise<string | {
|
|
4
|
+
out: string;
|
|
5
|
+
code: number;
|
|
6
|
+
}>;
|
|
7
|
+
/** Record an explicit hand-off: the tier reads DISARMED, a stood-down tier, not a dead one. */
|
|
3
8
|
export declare function standDownTier(repoRoot: string, tier: SupervisionTier, seat: string): string;
|