great-cto 3.60.0 → 3.62.0
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/board/.claude-plugin/plugin.json +23 -1
- package/board/packages/board/lib/config.mjs +11 -4
- package/board/packages/board/lib/data-readers.mjs +4 -6
- package/board/packages/board/lib/projects.mjs +48 -17
- package/board/packages/board/lib/routes.mjs +2 -1
- package/board/packages/board/push-adapter.mjs +3 -3
- package/board/packages/cli/src/private-state.mjs +43 -0
- package/board/packages/cli/src/state-home.mjs +15 -0
- package/board/scripts/codex-pipeline.mjs +34 -5
- package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
- package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
- package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
- package/board/scripts/lib/agent-execution-budget.mjs +143 -0
- package/board/scripts/lib/approval-level.mjs +174 -0
- package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
- package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
- package/board/scripts/lib/claude-exec.mjs +11 -3
- package/board/scripts/lib/codex-checks.mjs +77 -6
- package/board/scripts/lib/codex-exec.mjs +13 -2
- package/board/scripts/lib/codex-host-state.mjs +2 -0
- package/board/scripts/lib/codex-pipeline.mjs +281 -38
- package/board/scripts/lib/codex-release.mjs +9 -5
- package/board/scripts/lib/codex-role-profiles.mjs +46 -1
- package/board/scripts/lib/controlled-specialists.mjs +150 -0
- package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
- package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
- package/board/scripts/lib/receipt.mjs +31 -6
- package/board/scripts/lib/review-graph-floor.mjs +54 -0
- package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
- package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
- package/board/scripts/lib/specialist-plan.mjs +102 -0
- package/board/scripts/lib/turn-snapshot.mjs +24 -4
- package/dist/codex-host.js +1 -1
- package/dist/private-state.mjs +53 -0
- package/dist/serve.js +10 -5
- package/dist/state-home.mjs +15 -0
- package/dist/task-queue.js +2 -2
- package/dist/update-check.js +2 -5
- package/dist/webhook-cli.js +2 -2
- package/dist/webhook-config.js +18 -14
- package/dist/webhook-dispatch.js +5 -8
- package/dist/worker.js +3 -2
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/** Controller-owned
|
|
1
|
+
/** Controller-owned checks: Docker isolation or explicit trusted local execution. */
|
|
2
2
|
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, lstatSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
3
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { randomUUID, createHash } from 'node:crypto';
|
|
6
|
-
import { execFile } from 'node:child_process';
|
|
6
|
+
import { execFile, spawn } from 'node:child_process';
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { scan } from './secret-patterns.mjs';
|
|
9
9
|
import { artifactPath, validateArtifacts, exporter, bundleDigest } from './codex-artifacts.mjs';
|
|
@@ -12,10 +12,15 @@ const sha = value => createHash('sha256').update(value).digest('hex');
|
|
|
12
12
|
export const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
13
13
|
|
|
14
14
|
export function validateCheckPolicy(policy) {
|
|
15
|
-
if (!policy ||
|
|
15
|
+
if (!policy || !['docker', 'local'].includes(policy.backend === undefined ? 'docker' : policy.backend)) throw Error('unsupported checks backend');
|
|
16
|
+
if (policy.backend === 'local') {
|
|
17
|
+
if (policy.trusted !== true) throw Error('local checks require explicit trusted: true; local is not a sandbox');
|
|
18
|
+
if (policy.image !== undefined) throw Error('local checks cannot claim a container image');
|
|
19
|
+
} else if (!/^[a-z0-9][a-z0-9./:_-]*@sha256:[a-f0-9]{64}$/.test(policy.image || '')) throw Error('checks require an image pinned by sha256 digest');
|
|
16
20
|
if (!Array.isArray(policy.inputs) || !policy.inputs.length || policy.inputs.length > 100 || policy.inputs.some(p => typeof p !== 'string')) throw Error('checks require explicit input paths');
|
|
17
21
|
if (!Array.isArray(policy.commands) || !policy.commands.length || policy.commands.length > 10 ||
|
|
18
22
|
policy.commands.some(argv => !Array.isArray(argv) || !argv.length || argv.length > 100 || argv.some(a => typeof a !== 'string' || a.includes('\0')))) throw Error('checks require command argv arrays');
|
|
23
|
+
if (policy.backend === 'local' && policy.commands.some(argv => argv[0] !== 'node' && !isAbsolute(argv[0]))) throw Error('local executable must be node or an absolute path');
|
|
19
24
|
if (!Number.isInteger(policy.timeoutMs) || policy.timeoutMs < 1000 || policy.timeoutMs > 300000) throw Error('checks timeout must be 1000..300000 ms');
|
|
20
25
|
if (policy.outputs !== undefined) {
|
|
21
26
|
if (!Array.isArray(policy.outputs) || !policy.outputs.length || policy.outputs.length > 50 || new Set(policy.outputs).size !== policy.outputs.length) throw Error('outputs must name 1..50 distinct files');
|
|
@@ -24,6 +29,65 @@ export function validateCheckPolicy(policy) {
|
|
|
24
29
|
return policy;
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
// No shell, inherited credentials or workspace cwd. This reduces accidental
|
|
33
|
+
// exposure, NOT filesystem/network access by trusted code running as the user.
|
|
34
|
+
function invokeLocal(executable, args, cwd, timeout) {
|
|
35
|
+
return new Promise(resolve => {
|
|
36
|
+
let stdout = '', stderr = '', size = 0, killed = false;
|
|
37
|
+
const child = spawn(executable, args, { cwd, shell: false, detached: process.platform !== 'win32',
|
|
38
|
+
env: { PATH: dirname(process.execPath), HOME: cwd, TMPDIR: cwd, LANG: 'C', LC_ALL: 'C' },
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'] });
|
|
40
|
+
const stop = () => {
|
|
41
|
+
try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL'); } catch {}
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => { killed = true; stop(); }, timeout);
|
|
44
|
+
const capture = key => data => {
|
|
45
|
+
size += data.length;
|
|
46
|
+
if (size > 16 * 1024 * 1024) { killed = true; stop(); return; }
|
|
47
|
+
if (key === 'stdout') stdout += data.toString('utf8'); else stderr += data.toString('utf8');
|
|
48
|
+
};
|
|
49
|
+
child.stdout.on('data', capture('stdout')); child.stderr.on('data', capture('stderr'));
|
|
50
|
+
child.on('error', error => { clearTimeout(timer); resolve({ code: null, stdout, stderr: error.message, killed }); });
|
|
51
|
+
child.on('close', code => {
|
|
52
|
+
clearTimeout(timer); stop(); // also stop same-process-group descendants
|
|
53
|
+
resolve({ code, stdout, stderr, killed });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runLocal(policy, input) {
|
|
59
|
+
const runtime = { node: process.version, platform: process.platform, arch: process.arch, executables: {} };
|
|
60
|
+
let stdout = '', stderr = '';
|
|
61
|
+
const deadline = Date.now() + policy.timeoutMs;
|
|
62
|
+
for (const argv of policy.commands) {
|
|
63
|
+
const executable = argv[0] === 'node' ? process.execPath : argv[0];
|
|
64
|
+
try { runtime.executables[executable] = sha(readFileSync(executable)); }
|
|
65
|
+
catch (error) { return { code: null, state: 'unverifiable', stdout, stderr: error.message, runtime }; }
|
|
66
|
+
const remaining = deadline - Date.now();
|
|
67
|
+
if (remaining <= 0) return { code: null, killed: true, state: 'unverifiable', stdout, stderr, runtime };
|
|
68
|
+
const result = await invokeLocal(executable, argv.slice(1), input, remaining);
|
|
69
|
+
stdout += result.stdout; stderr += result.stderr;
|
|
70
|
+
if (result.killed || result.code === null || stdout.length + stderr.length > 16 * 1024 * 1024)
|
|
71
|
+
return { ...result, state: 'unverifiable', stdout: stdout.slice(0, 8 * 1024 * 1024), stderr: stderr.slice(0, 8 * 1024 * 1024), runtime };
|
|
72
|
+
if (result.code !== 0) return { ...result, state: 'failed', stdout, stderr, runtime };
|
|
73
|
+
}
|
|
74
|
+
let artifacts = null;
|
|
75
|
+
if (policy.outputs) {
|
|
76
|
+
let bytes = 0;
|
|
77
|
+
artifacts = validateArtifacts(policy.outputs.map(path => {
|
|
78
|
+
let current = input;
|
|
79
|
+
for (const part of path.split('/')) {
|
|
80
|
+
current = join(current, part);
|
|
81
|
+
if (lstatSync(current).isSymbolicLink()) throw Error('symlink artifact');
|
|
82
|
+
}
|
|
83
|
+
const stat = lstatSync(current);
|
|
84
|
+
if (!stat.isFile() || (bytes += stat.size) > 8 * 1024 * 1024) throw Error('invalid or oversized artifact');
|
|
85
|
+
return { path, base64: readFileSync(current).toString('base64') };
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
return { code: 0, state: 'passed', stdout, stderr, runtime, artifacts };
|
|
89
|
+
}
|
|
90
|
+
|
|
27
91
|
async function invoke(args, timeout) {
|
|
28
92
|
try {
|
|
29
93
|
const result = await exec('docker', args, { timeout, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8' });
|
|
@@ -56,6 +120,13 @@ export async function runChecks(state, { safePath, invokeDocker = invoke } = {})
|
|
|
56
120
|
};
|
|
57
121
|
for (const path of policy.inputs) copy(path);
|
|
58
122
|
if (!Object.keys(files).length) throw Error('empty checks snapshot');
|
|
123
|
+
if (policy.backend === 'local') {
|
|
124
|
+
const result = await runLocal(policy, input);
|
|
125
|
+
return { ...result, backend: 'local', isolation: 'none', trusted: true, startedAt,
|
|
126
|
+
finishedAt: new Date().toISOString(), image: null, commands: policy.commands, files,
|
|
127
|
+
artifacts: result.artifacts ?? null, artifactDigest: result.artifacts ? bundleDigest(result.artifacts) : null,
|
|
128
|
+
inputDigest: sha(JSON.stringify(files)), policyDigest: sha(JSON.stringify(policy)) };
|
|
129
|
+
}
|
|
59
130
|
const commands = policy.commands.map(argv => argv.map(shellQuote).join(' ') + (policy.outputs ? ' 1>&2' : '')).join('\n');
|
|
60
131
|
const script = 'set -eu\ncp -R /input/. /work/\n' + commands + (policy.outputs ? `\nnode -e ${shellQuote(exporter)} ${shellQuote(JSON.stringify(policy.outputs))}` : '');
|
|
61
132
|
const args = ['run', '--rm', '--pull=never', '--name', name, '--network=none', '--read-only', '--cap-drop=ALL',
|
|
@@ -71,14 +142,14 @@ export async function runChecks(state, { safePath, invokeDocker = invoke } = {})
|
|
|
71
142
|
if (JSON.stringify(artifacts.map(a => a.path).sort()) !== JSON.stringify([...policy.outputs].sort())) throw Error('exported artifact set differs from policy');
|
|
72
143
|
result.stdout = '[artifact data retained separately]';
|
|
73
144
|
}
|
|
74
|
-
return { ...result, state: outcome, startedAt,
|
|
145
|
+
return { ...result, backend: 'docker', isolation: 'container', state: outcome, startedAt,
|
|
75
146
|
artifacts, artifactDigest: artifacts ? bundleDigest(artifacts) : null,
|
|
76
147
|
finishedAt: new Date().toISOString(), image: policy.image, commands: policy.commands, files,
|
|
77
148
|
inputDigest: sha(JSON.stringify(files)), policyDigest: sha(JSON.stringify(policy)) };
|
|
78
149
|
} finally {
|
|
79
150
|
// A killed Docker client can leave its container alive. Remove this exact
|
|
80
151
|
// per-invocation container, never another task's container or a broad prune.
|
|
81
|
-
await invokeDocker(['rm', '-f', name], 10000);
|
|
152
|
+
if (policy.backend !== 'local') await invokeDocker(['rm', '-f', name], 10000);
|
|
82
153
|
rmSync(stage, { recursive: true, force: true });
|
|
83
154
|
}
|
|
84
155
|
}
|
|
@@ -130,6 +130,7 @@ export function runCodexExec({
|
|
|
130
130
|
let out = '';
|
|
131
131
|
let err = '';
|
|
132
132
|
let timedOut = false;
|
|
133
|
+
let stdinError = null;
|
|
133
134
|
const killGroup = () => {
|
|
134
135
|
try {
|
|
135
136
|
if (group && proc.pid) process.kill(-proc.pid, 'SIGKILL');
|
|
@@ -158,6 +159,13 @@ export function runCodexExec({
|
|
|
158
159
|
}
|
|
159
160
|
});
|
|
160
161
|
proc.stderr.on('data', (b) => { err += String(b); });
|
|
162
|
+
const inputFailed = error => {
|
|
163
|
+
stdinError ||= typeof error?.code === 'string' ? error.code : 'STDIN_ERROR';
|
|
164
|
+
// The CLI cannot receive the complete request. Abort its group, but wait
|
|
165
|
+
// for authoritative close before returning a failed execution result.
|
|
166
|
+
killGroup();
|
|
167
|
+
};
|
|
168
|
+
proc.stdin.on('error', inputFailed);
|
|
161
169
|
proc.on('error', (e) => {
|
|
162
170
|
clearTimeout(timer);
|
|
163
171
|
resolve({ state: 'unreadable', text: null, usage: null, errors: [String(e.message || e)], code: null, model, timedOut });
|
|
@@ -174,6 +182,10 @@ export function runCodexExec({
|
|
|
174
182
|
// classify the exact known fallback nor distinguish it from a sandbox
|
|
175
183
|
// or validation failure.
|
|
176
184
|
if (err.trim()) parsed.errors.push(err.trim().slice(-4000));
|
|
185
|
+
if (stdinError) {
|
|
186
|
+
parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null;
|
|
187
|
+
parsed.errors.push(`prompt transport failed: ${stdinError}`);
|
|
188
|
+
}
|
|
177
189
|
// A run cut off by the clock may have printed a verdict before it was done.
|
|
178
190
|
// That is a truncated answer, and a truncated answer is not an answer.
|
|
179
191
|
if (timedOut) {
|
|
@@ -183,8 +195,7 @@ export function runCodexExec({
|
|
|
183
195
|
resolve({ ...parsed, code, model, timedOut });
|
|
184
196
|
});
|
|
185
197
|
|
|
186
|
-
proc.stdin.
|
|
187
|
-
proc.stdin.end();
|
|
198
|
+
try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
|
|
188
199
|
});
|
|
189
200
|
}
|
|
190
201
|
|
|
@@ -5,6 +5,7 @@ import { basename, join } from 'node:path';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { detectCodex } from './codex-exec.mjs';
|
|
7
7
|
import { detectClaude } from './claude-exec.mjs';
|
|
8
|
+
import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
|
|
8
9
|
|
|
9
10
|
export const codexRunStore = ({ home = homedir(), env = process.env } = {}) =>
|
|
10
11
|
env.GREAT_CTO_CODEX_RUNS_DIR || join(home, '.great_cto', 'codex-runs');
|
|
@@ -19,6 +20,7 @@ export function projectCodexState(state) {
|
|
|
19
20
|
active: state.active,
|
|
20
21
|
queue: state.queue || [],
|
|
21
22
|
hostRoutes: state.hostRoutes || {},
|
|
23
|
+
controllerDispatch: dispatchEvidenceSummary(state),
|
|
22
24
|
wave: state.wave ? { id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
|
|
23
25
|
rolesCompleted: Object.keys(state.results || {}),
|
|
24
26
|
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt }) =>
|