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.
Files changed (43) hide show
  1. package/board/.claude-plugin/plugin.json +23 -1
  2. package/board/packages/board/lib/config.mjs +11 -4
  3. package/board/packages/board/lib/data-readers.mjs +4 -6
  4. package/board/packages/board/lib/projects.mjs +48 -17
  5. package/board/packages/board/lib/routes.mjs +2 -1
  6. package/board/packages/board/push-adapter.mjs +3 -3
  7. package/board/packages/cli/src/private-state.mjs +43 -0
  8. package/board/packages/cli/src/state-home.mjs +15 -0
  9. package/board/scripts/codex-pipeline.mjs +34 -5
  10. package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
  11. package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
  12. package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
  13. package/board/scripts/lib/agent-execution-budget.mjs +143 -0
  14. package/board/scripts/lib/approval-level.mjs +174 -0
  15. package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
  16. package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
  17. package/board/scripts/lib/claude-exec.mjs +11 -3
  18. package/board/scripts/lib/codex-checks.mjs +77 -6
  19. package/board/scripts/lib/codex-exec.mjs +13 -2
  20. package/board/scripts/lib/codex-host-state.mjs +2 -0
  21. package/board/scripts/lib/codex-pipeline.mjs +281 -38
  22. package/board/scripts/lib/codex-release.mjs +9 -5
  23. package/board/scripts/lib/codex-role-profiles.mjs +46 -1
  24. package/board/scripts/lib/controlled-specialists.mjs +150 -0
  25. package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
  26. package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
  27. package/board/scripts/lib/receipt.mjs +31 -6
  28. package/board/scripts/lib/review-graph-floor.mjs +54 -0
  29. package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
  30. package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
  31. package/board/scripts/lib/specialist-plan.mjs +102 -0
  32. package/board/scripts/lib/turn-snapshot.mjs +24 -4
  33. package/dist/codex-host.js +1 -1
  34. package/dist/private-state.mjs +53 -0
  35. package/dist/serve.js +10 -5
  36. package/dist/state-home.mjs +15 -0
  37. package/dist/task-queue.js +2 -2
  38. package/dist/update-check.js +2 -5
  39. package/dist/webhook-cli.js +2 -2
  40. package/dist/webhook-config.js +18 -14
  41. package/dist/webhook-dispatch.js +5 -8
  42. package/dist/worker.js +3 -2
  43. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
- /** Controller-owned, offline Docker checks. No host workspace or credentials mounted. */
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 || !/^[a-z0-9][a-z0-9./:_-]*@sha256:[a-f0-9]{64}$/.test(policy.image || '')) throw Error('checks require an image pinned by sha256 digest');
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.write(prompt);
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 }) =>