great-cto 3.28.8 → 3.29.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 +12 -2
- package/board/packages/board/lib/agent-activity.mjs +53 -0
- package/board/packages/board/lib/gate-tokens.mjs +151 -0
- package/board/packages/board/lib/routes.mjs +102 -1
- package/board/packages/board/lib/util.mjs +34 -6
- package/board/packages/board/lib/watchers.mjs +34 -1
- package/board/packages/board/public/index.html +204 -27
- package/board/packages/board/server.mjs +19 -2
- package/board/scripts/codex-pipeline.mjs +80 -0
- package/board/scripts/lib/agent-events.mjs +191 -0
- package/board/scripts/lib/codex-artifacts.mjs +41 -0
- package/board/scripts/lib/codex-checks.mjs +84 -0
- package/board/scripts/lib/codex-exec.mjs +65 -6
- package/board/scripts/lib/codex-github-release.mjs +146 -0
- package/board/scripts/lib/codex-host-state.mjs +93 -0
- package/board/scripts/lib/codex-pipeline.mjs +460 -0
- package/board/scripts/lib/codex-release.mjs +167 -0
- package/board/scripts/lib/codex-role-profiles.mjs +49 -0
- package/board/scripts/lib/pipeline-toml.mjs +21 -0
- package/board/scripts/lib/receipt.mjs +114 -7
- package/board/scripts/lib/secret-patterns.mjs +56 -0
- package/board/scripts/lib/turn-snapshot.mjs +212 -0
- package/board/shared/pipeline.toml +323 -0
- package/dist/codex-host.js +29 -0
- package/dist/codex.js +3 -2
- package/dist/main.js +18 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.29.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -121,6 +121,11 @@
|
|
|
121
121
|
"command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/subagent-stop-completion.mjs\" 2>&1; EXIT=$?; if [ $EXIT -eq 2 ]; then exit 2; fi; exit 0",
|
|
122
122
|
"timeout": 5,
|
|
123
123
|
"statusMessage": "Three-state completion check..."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"type": "command",
|
|
127
|
+
"async": true,
|
|
128
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
|
|
124
129
|
}
|
|
125
130
|
]
|
|
126
131
|
}
|
|
@@ -272,7 +277,7 @@
|
|
|
272
277
|
"hooks": [
|
|
273
278
|
{
|
|
274
279
|
"type": "command",
|
|
275
|
-
"command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true",
|
|
280
|
+
"command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true; node \"${CLAUDE_PLUGIN_ROOT}/scripts/lib/agent-events.mjs\" --emit denied --tool \"$TOOL\" 2>/dev/null || true",
|
|
276
281
|
"timeout": 3
|
|
277
282
|
}
|
|
278
283
|
]
|
|
@@ -288,6 +293,11 @@
|
|
|
288
293
|
{
|
|
289
294
|
"type": "command",
|
|
290
295
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/cross-review-gate.mjs\" 2>/dev/null || true"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"type": "command",
|
|
299
|
+
"async": true,
|
|
300
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
|
|
291
301
|
}
|
|
292
302
|
]
|
|
293
303
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Agent activity for the board (ADR-021, phase 1).
|
|
2
|
+
//
|
|
3
|
+
// The hooks append agent events to <project>/.great_cto/events.jsonl through
|
|
4
|
+
// scripts/lib/agent-events.mjs; this reads them for one project, and gives the
|
|
5
|
+
// watcher a cheap stamp to notice that something new arrived.
|
|
6
|
+
//
|
|
7
|
+
// Three states come straight from the reader and are passed through unchanged:
|
|
8
|
+
// `none` (no file — no events recorded, which is not an idle agent), `live`, and
|
|
9
|
+
// `unreadable` (a file the board could not read — not zero events). `attention`
|
|
10
|
+
// is the pipeline events whose outcome means the chain did not move: on
|
|
11
|
+
// 2026-09-14 a run stopped on `no-rule` and nothing on the board said so.
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { readEvents, readEventsSince, EVENTS_FILE } from '../../../scripts/lib/agent-events.mjs';
|
|
16
|
+
|
|
17
|
+
/** Journal outcomes after which the pipeline did not move on. */
|
|
18
|
+
export const STALLED_OUTCOMES = Object.freeze(['no-rule', 'no-verdict', 'unknown-verdict', 'no-map', 'breaker', 'blocked-budget']);
|
|
19
|
+
|
|
20
|
+
export function agentActivity(cwd, { limit = 20 } = {}) {
|
|
21
|
+
const r = readEvents(path.join(cwd, '.great_cto'), { limit });
|
|
22
|
+
const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
|
|
23
|
+
return { state: r.state, why: r.why ?? null, events: r.events, attention, bad: r.bad ?? 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The same, resumable (ADR-021 phase 2): events after `cursor` as a `delta`, or a
|
|
28
|
+
* `snapshot` when the cursor no longer points into this project's file. `cursor` is
|
|
29
|
+
* what the next call should pass; `gap` says a snapshot replaced more events than a
|
|
30
|
+
* replay sends. `attention` covers only the events returned — the page merges.
|
|
31
|
+
*/
|
|
32
|
+
export function agentActivitySince(cwd, cursor, { limit = 20 } = {}) {
|
|
33
|
+
const r = readEventsSince(path.join(cwd, '.great_cto'), cursor, { limit });
|
|
34
|
+
const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
|
|
35
|
+
return {
|
|
36
|
+
state: r.state, mode: r.mode ?? 'snapshot', why: r.why ?? null, events: r.events, attention,
|
|
37
|
+
bad: r.bad ?? 0, cursor: r.cursor, gap: r.gap === true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the watcher compares between polls. `none` when there is no file; the
|
|
43
|
+
* error code when it cannot be stat'ed, so a file that turns unreadable is a
|
|
44
|
+
* change too.
|
|
45
|
+
*/
|
|
46
|
+
export function activityStamp(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const s = fs.statSync(path.join(cwd, '.great_cto', EVENTS_FILE));
|
|
49
|
+
return `${s.size}:${s.mtimeMs}`;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return err?.code === 'ENOENT' ? 'none' : `error:${err?.code || 'unknown'}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Gate approval tokens (ADR-024 §1).
|
|
2
|
+
//
|
|
3
|
+
// POST /api/gates/:id closed a gate, logged it, woke the pipeline and, with sharing
|
|
4
|
+
// on, republished the public report — guarded by the Host allowlist and the Origin
|
|
5
|
+
// check alone. The controlled Codex host already refused an approval without the
|
|
6
|
+
// token issued for that pending gate, or after the tree changed; the board did not.
|
|
7
|
+
//
|
|
8
|
+
// A token here is minted when the board lists a pending gate, is single-use,
|
|
9
|
+
// expires, and is bound to the project tree OUTSIDE .great_cto/ as it was when the
|
|
10
|
+
// board first showed the gate. .great_cto/ is excluded because an approval writes
|
|
11
|
+
// the pipeline's own files there (gate row, decision log, wake record, this store),
|
|
12
|
+
// and `init` does not gitignore it: bound to the whole tree, the first approval
|
|
13
|
+
// would invalidate every other open gate though no reviewed code moved.
|
|
14
|
+
//
|
|
15
|
+
// Every check fails closed and says why. A store that cannot be read issues no
|
|
16
|
+
// tokens and accepts none — it is never silently overwritten.
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { randomUUID } from 'crypto';
|
|
21
|
+
import { treeReceipt, receiptHash, compareReceipts } from '../../../scripts/lib/receipt.mjs';
|
|
22
|
+
|
|
23
|
+
export const TOKENS_FILE = 'gate-tokens.json';
|
|
24
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
// Why these are outside the binding: both are the pipeline's own state, and a
|
|
26
|
+
// decision itself writes to them — .great_cto/ gets the gate row, decision log, wake
|
|
27
|
+
// and this store; .beads/ gets `bd update`'s interactions.jsonl. Counting either would
|
|
28
|
+
// let one approval refuse every other open gate as "changed". .beads/ was found by
|
|
29
|
+
// tests/pipeline-e2e.test.mjs: approving gate:plan made gate:ship read stale.
|
|
30
|
+
export const BINDING_EXCLUDE = Object.freeze(['.great_cto', '.beads']);
|
|
31
|
+
|
|
32
|
+
/** Test seam: GREAT_CTO_GATE_TOKEN_TTL_MS; production is 24 h. */
|
|
33
|
+
export function tokenTtlMs(env = process.env) {
|
|
34
|
+
const n = Number(env.GREAT_CTO_GATE_TOKEN_TTL_MS);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const storePath = (cwd) => path.join(cwd, '.great_cto', TOKENS_FILE);
|
|
39
|
+
|
|
40
|
+
/** @returns {{state:'none'|'ok'|'unreadable', tokens:object, why?:string}} */
|
|
41
|
+
export function readTokens(cwd) {
|
|
42
|
+
let raw;
|
|
43
|
+
try { raw = fs.readFileSync(storePath(cwd), 'utf8'); }
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err?.code === 'ENOENT') return { state: 'none', tokens: {} };
|
|
46
|
+
return { state: 'unreadable', tokens: {}, why: String(err?.code || err?.message || err) };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Error('not an object');
|
|
51
|
+
return { state: 'ok', tokens: parsed };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { state: 'unreadable', tokens: {}, why: `the token store could not be parsed: ${err.message}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeTokens(cwd, tokens) {
|
|
58
|
+
fs.mkdirSync(path.join(cwd, '.great_cto'), { recursive: true });
|
|
59
|
+
const file = storePath(cwd);
|
|
60
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 });
|
|
62
|
+
fs.renameSync(tmp, file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The tree a token is bound to: the project outside .great_cto/. Null outside git. */
|
|
66
|
+
export function bindingReceipt(cwd) {
|
|
67
|
+
return treeReceipt(cwd, { exclude: BINDING_EXCLUDE });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tokens for the pending gates, minting what is missing or expired.
|
|
72
|
+
*
|
|
73
|
+
* `gates` is the FULL pending list — entries for gates no longer pending are
|
|
74
|
+
* dropped. At most one receipt is computed per call (about 300 ms on a real tree),
|
|
75
|
+
* and only when something has to be minted.
|
|
76
|
+
*
|
|
77
|
+
* @param {{id:string, gate?:string, guarded?:boolean}[]} gates
|
|
78
|
+
* @returns {{state:'ok'|'unreadable', tokens:Record<string,string>, why?:string}}
|
|
79
|
+
*/
|
|
80
|
+
export function issueTokens(cwd, gates, { now = Date.now(), env = process.env } = {}) {
|
|
81
|
+
const store = readTokens(cwd);
|
|
82
|
+
if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
|
|
83
|
+
const ttl = tokenTtlMs(env);
|
|
84
|
+
const pending = new Map((gates || []).filter((g) => g && typeof g.id === 'string' && g.id).map((g) => [g.id, g]));
|
|
85
|
+
const next = {};
|
|
86
|
+
const toMint = [];
|
|
87
|
+
for (const [id, g] of pending) {
|
|
88
|
+
const e = store.tokens[id];
|
|
89
|
+
if (e && typeof e.token === 'string' && Number.isFinite(e.firstSeenAt) && now - e.firstSeenAt <= ttl) next[id] = e;
|
|
90
|
+
else toMint.push(g);
|
|
91
|
+
}
|
|
92
|
+
if (toMint.length) {
|
|
93
|
+
const receipt = bindingReceipt(cwd);
|
|
94
|
+
const hash = receiptHash(receipt);
|
|
95
|
+
for (const g of toMint) {
|
|
96
|
+
next[g.id] = {
|
|
97
|
+
token: randomUUID(), firstSeenAt: now, receiptHash: hash, receipt,
|
|
98
|
+
gate: typeof g.gate === 'string' ? g.gate : '', guarded: g.guarded === true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const changed = toMint.length || Object.keys(store.tokens).some((id) => !pending.has(id));
|
|
103
|
+
if (changed) writeTokens(cwd, next);
|
|
104
|
+
return { state: 'ok', tokens: Object.fromEntries(Object.entries(next).map(([id, e]) => [id, e.token])) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Is `token` the live token for gate `id`?
|
|
109
|
+
* @returns {{ok:true, entry:object} | {ok:false, status:number, outcome:string, why:string}}
|
|
110
|
+
*/
|
|
111
|
+
export function checkToken(cwd, id, token, { now = Date.now(), env = process.env } = {}) {
|
|
112
|
+
const store = readTokens(cwd);
|
|
113
|
+
if (store.state === 'unreadable') {
|
|
114
|
+
return { ok: false, status: 403, outcome: 'refused-unreadable', why: `approval tokens could not be read: ${store.why}` };
|
|
115
|
+
}
|
|
116
|
+
const entry = store.tokens[id];
|
|
117
|
+
if (!entry || typeof token !== 'string' || !token || token !== entry.token) {
|
|
118
|
+
return { ok: false, status: 403, outcome: 'refused-token', why: 'no valid approval token for this gate — reload the inbox and try again' };
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isFinite(entry.firstSeenAt) || now - entry.firstSeenAt > tokenTtlMs(env)) {
|
|
121
|
+
return { ok: false, status: 403, outcome: 'refused-expired', why: 'this approval token has expired — reload the inbox to see the gate as it is now' };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, entry };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Is the tree outside .great_cto/ still the one the gate was shown with?
|
|
128
|
+
* @returns {{ok:true, bound:boolean} | {ok:false, status:409, outcome:'refused-stale', why:string, changed:string[]}}
|
|
129
|
+
*/
|
|
130
|
+
export function checkBinding(cwd, entry) {
|
|
131
|
+
const current = bindingReceipt(cwd);
|
|
132
|
+
if (!entry.receipt && !current) return { ok: true, bound: false }; // not a git repository: nothing to bind to, and said so
|
|
133
|
+
if (receiptHash(current) === entry.receiptHash) return { ok: true, bound: true };
|
|
134
|
+
const cmp = compareReceipts(entry.receipt, current, { cwd });
|
|
135
|
+
const changed = [...(cmp.changed || []), ...(cmp.removed || []), ...(cmp.added || [])];
|
|
136
|
+
return {
|
|
137
|
+
ok: false, status: 409, outcome: 'refused-stale', changed,
|
|
138
|
+
why: changed.length
|
|
139
|
+
? `the project changed since this gate was shown: ${changed.slice(0, 10).join(', ')}${changed.length > 10 ? ` and ${changed.length - 10} more` : ''}`
|
|
140
|
+
: 'the project changed since this gate was shown (a commit or an unreadable tree)',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Single use: drop the gate's token once it has been decided. */
|
|
145
|
+
export function consumeToken(cwd, id) {
|
|
146
|
+
const store = readTokens(cwd);
|
|
147
|
+
if (store.state !== 'ok' || !(id in store.tokens)) return;
|
|
148
|
+
const next = { ...store.tokens };
|
|
149
|
+
delete next[id];
|
|
150
|
+
writeTokens(cwd, next);
|
|
151
|
+
}
|
|
@@ -20,10 +20,20 @@ import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from
|
|
|
20
20
|
import { log } from './log.mjs';
|
|
21
21
|
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
|
|
22
22
|
import { getMetrics } from './metrics.mjs';
|
|
23
|
+
import { agentActivity, agentActivitySince } from './agent-activity.mjs';
|
|
24
|
+
import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
|
|
25
|
+
import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
|
|
26
|
+
import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
|
|
27
|
+
|
|
28
|
+
/** A gate decision — made or refused — as an agent event (ADR-024 §1). */
|
|
29
|
+
function recordGateEvent(cwd, id, outcome) {
|
|
30
|
+
return appendEvent(path.join(cwd, '.great_cto'), { kind: 'pipeline', agent: id, outcome });
|
|
31
|
+
}
|
|
23
32
|
import { readVerdicts } from './verdicts.mjs';
|
|
24
33
|
import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
|
|
25
34
|
import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
|
|
26
35
|
import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
|
|
36
|
+
import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
|
|
27
37
|
import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
|
|
28
38
|
// Moved to scripts/lib so the cross-review Stop hook can ask the same question
|
|
29
39
|
// without importing a server. Re-exported here: this module's callers are unchanged.
|
|
@@ -92,6 +102,19 @@ async function dispatch(req, res, url, cwd) {
|
|
|
92
102
|
res._gctoCwd = cwd; // remember which project this client wants
|
|
93
103
|
sseClients.add(res);
|
|
94
104
|
res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
|
|
105
|
+
// ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
|
|
106
|
+
// Phase 2: a client that hands back the cursor of the last frame it saw — the
|
|
107
|
+
// Last-Event-ID header, or ?since= from a page that had to recreate its
|
|
108
|
+
// EventSource — gets only what came after it. Each frame names its cursor.
|
|
109
|
+
let a;
|
|
110
|
+
try {
|
|
111
|
+
a = agentActivitySince(cwd, req.headers['last-event-id'] || url.searchParams.get('since') || null);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Not "no events": the strip must say it could not measure them.
|
|
114
|
+
a = { state: 'unreadable', mode: 'snapshot', why: String(err?.message || err), events: [], attention: [], bad: 0, cursor: null, gap: false };
|
|
115
|
+
}
|
|
116
|
+
res._gctoAgentCursor = a.cursor;
|
|
117
|
+
res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
|
|
95
118
|
req.on('close', () => sseClients.delete(res));
|
|
96
119
|
return true;
|
|
97
120
|
}
|
|
@@ -520,6 +543,26 @@ async function dispatch(req, res, url, cwd) {
|
|
|
520
543
|
res.end(JSON.stringify({ error: 'invalid action' }));
|
|
521
544
|
return;
|
|
522
545
|
}
|
|
546
|
+
// ADR-024 §1 — an approval is bound to one request and one state. Checked before
|
|
547
|
+
// anything is written: a refused request changes no gate, logs no decision,
|
|
548
|
+
// wakes nothing and publishes nothing. A rejection needs the token too (a forged
|
|
549
|
+
// reject is still a forged request) but is never refused for a changed tree.
|
|
550
|
+
const refuse = (r) => {
|
|
551
|
+
recordGateEvent(gateCwd, id, r.outcome);
|
|
552
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json' });
|
|
553
|
+
res.end(JSON.stringify({ error: r.why, outcome: r.outcome, ...(r.changed ? { changed: r.changed } : {}) }));
|
|
554
|
+
};
|
|
555
|
+
const tok = checkToken(gateCwd, id, parsed.token);
|
|
556
|
+
if (!tok.ok) { refuse(tok); return; }
|
|
557
|
+
if (action === 'approve') {
|
|
558
|
+
// The typed-name ritual used to live only in the page; the server now agrees.
|
|
559
|
+
if (tok.entry.guarded && String(parsed.confirm ?? '').trim() !== tok.entry.gate) {
|
|
560
|
+
refuse({ status: 403, outcome: 'refused-confirm', why: `this gate is expensive to undo — type ${tok.entry.gate} to approve it` });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const bound = checkBinding(gateCwd, tok.entry);
|
|
564
|
+
if (!bound.ok) { refuse(bound); return; }
|
|
565
|
+
}
|
|
523
566
|
// A project can be tasks.md-backed (no working beads — e.g. its path
|
|
524
567
|
// contains a space, which embedded-dolt can't open). Only 409 when there
|
|
525
568
|
// is neither a beads store NOR a tasks.md to record the decision in.
|
|
@@ -610,6 +653,9 @@ async function dispatch(req, res, url, cwd) {
|
|
|
610
653
|
res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
|
|
611
654
|
return;
|
|
612
655
|
}
|
|
656
|
+
// Single use: the decision landed, so its token is spent.
|
|
657
|
+
consumeToken(gateCwd, id);
|
|
658
|
+
recordGateEvent(gateCwd, id, action === 'approve' ? 'approved' : 'rejected');
|
|
613
659
|
// An approval is evidence that the pipeline is waiting — record it.
|
|
614
660
|
//
|
|
615
661
|
// `session-pipeline-resume` opens with a freshness shortcut: a pipeline
|
|
@@ -677,8 +723,19 @@ async function dispatch(req, res, url, cwd) {
|
|
|
677
723
|
let elsewhere;
|
|
678
724
|
try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
|
|
679
725
|
catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
|
|
726
|
+
// ADR-024 §1: each pending gate carries the token an approval must present,
|
|
727
|
+
// bound to the project as it is now. The typed name stored with it is the one
|
|
728
|
+
// the page asks for; a store that cannot be read issues nothing and says so.
|
|
729
|
+
const gates = inbox.pending_gates || [];
|
|
730
|
+
const issued = issueTokens(cwd, gates.map((g) => ({
|
|
731
|
+
id: g.id,
|
|
732
|
+
gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
|
|
733
|
+
guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
|
|
734
|
+
})));
|
|
735
|
+
const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
|
|
736
|
+
const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
|
|
680
737
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
681
|
-
res.end(JSON.stringify({ ...inbox, elsewhere, second_opinion }));
|
|
738
|
+
res.end(JSON.stringify({ ...inbox, pending_gates, approval_tokens, elsewhere, second_opinion }));
|
|
682
739
|
return true;
|
|
683
740
|
}
|
|
684
741
|
|
|
@@ -902,12 +959,56 @@ async function dispatch(req, res, url, cwd) {
|
|
|
902
959
|
}
|
|
903
960
|
|
|
904
961
|
// Pipeline — current stage states (idle / active / done / failed)
|
|
962
|
+
// ADR-021: agent events for this project — three states, never an empty list
|
|
963
|
+
// that could mean either "nothing happened" or "could not read".
|
|
964
|
+
if (pathname === '/api/agent-events') {
|
|
965
|
+
let limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
966
|
+
if (!Number.isFinite(limit) || limit < 1) limit = 20;
|
|
967
|
+
if (limit > 200) limit = 200;
|
|
968
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
969
|
+
res.end(JSON.stringify(agentActivity(cwd, { limit })));
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ADR-023: what each agent turn changed. Turns are git refs in the project's own
|
|
974
|
+
// repository; these read them and nothing else. A diff is file content, served only
|
|
975
|
+
// on a Host the board knows (xq9h) and escaped by the page before it is drawn.
|
|
976
|
+
if (pathname === '/api/turns') {
|
|
977
|
+
let limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
|
978
|
+
if (!Number.isFinite(limit) || limit < 1) limit = 10;
|
|
979
|
+
if (limit > 50) limit = 50;
|
|
980
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
981
|
+
res.end(JSON.stringify(readTurns(cwd, { limit })));
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
if (pathname === '/api/turns/diff') {
|
|
985
|
+
const turnParam = url.searchParams.get('turn');
|
|
986
|
+
const turn = /^\d{1,9}$/.test(turnParam || '') ? Number(turnParam) : NaN;
|
|
987
|
+
const p = turnPatch(cwd, { session: url.searchParams.get('session') || '', turn });
|
|
988
|
+
const status = p.state === 'ok' ? 200 : p.state === 'invalid' ? 400 : 404;
|
|
989
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
990
|
+
res.end(JSON.stringify(p));
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
|
|
905
994
|
if (pathname === '/api/pipeline') {
|
|
906
995
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
907
996
|
res.end(JSON.stringify(getPipeline(cwd)));
|
|
908
997
|
return true;
|
|
909
998
|
}
|
|
910
999
|
|
|
1000
|
+
// Controlled Codex host runs are stored outside worker projects. Return the
|
|
1001
|
+
// shared sanitized projection only: prompts, approval tokens and artifact
|
|
1002
|
+
// bytes never cross into the browser payload.
|
|
1003
|
+
if (pathname === '/api/codex-runs') {
|
|
1004
|
+
const result = listCodexRuns({ root: cwd });
|
|
1005
|
+
const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
|
|
1006
|
+
if (result.state === 'degraded') headers['X-Board-Degraded'] = encodeURIComponent(`${result.unreadable} unreadable Codex run state file(s)`);
|
|
1007
|
+
res.writeHead(200, headers);
|
|
1008
|
+
res.end(JSON.stringify(result));
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
911
1012
|
// change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
|
|
912
1013
|
if (pathname === '/api/change-tier') {
|
|
913
1014
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { PORT } from './config.mjs';
|
|
3
|
+
import { PORT, HOST } from './config.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Escape a single value for inclusion in a CSV cell.
|
|
@@ -14,13 +14,41 @@ function csvCell(v) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Only same-origin (the board's own page) may make a state-changing request — a malicious page must not.
|
|
17
|
+
//
|
|
18
|
+
// "Same origin" used to mean: the Origin matches the Host this request arrived with.
|
|
19
|
+
// A DNS-rebinding page points its own domain at 127.0.0.1, so the browser sends
|
|
20
|
+
// Host: evil.test:PORT and Origin: http://evil.test:PORT — a perfect match. It could
|
|
21
|
+
// read every GET and pass this guard for POST, gate approvals included (great_cto-xq9h).
|
|
22
|
+
// So the Host itself must be a name the board was told about: loopback on its port,
|
|
23
|
+
// the concrete bind host, or an entry in GREAT_CTO_ALLOWED_HOSTS (a tunnel or hosted
|
|
24
|
+
// console). server.mjs checks it for every request; the Origin must name one too.
|
|
25
|
+
const WILDCARD_BINDS = new Set(['0.0.0.0', '::', '[::]', '']);
|
|
26
|
+
|
|
27
|
+
function parseAllowedHosts(raw = process.env.GREAT_CTO_ALLOWED_HOSTS) {
|
|
28
|
+
return String(raw || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function allowedHostList({ port = PORT, bindHost = HOST, extra = parseAllowedHosts() } = {}) {
|
|
32
|
+
const list = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
|
33
|
+
const b = String(bindHost || '').toLowerCase();
|
|
34
|
+
if (!WILDCARD_BINDS.has(b)) list.push(`${b.includes(':') && !b.startsWith('[') ? `[${b}]` : b}:${port}`);
|
|
35
|
+
return [...list, ...extra];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hostAllowed(hostHeader, opts = {}) {
|
|
39
|
+
if (typeof hostHeader !== 'string' || !hostHeader.trim()) return false;
|
|
40
|
+
return allowedHostList(opts).includes(hostHeader.trim().toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
function originAllowed(req) {
|
|
18
44
|
const o = req.headers.origin || req.headers.referer || '';
|
|
19
45
|
if (!o) return true; // same-origin fetch / curl with no Origin
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
let u;
|
|
47
|
+
try { u = new URL(o); } catch { return false; }
|
|
48
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
49
|
+
// u.host keeps an explicit port and drops a default one (https://x → "x"), which is
|
|
50
|
+
// how a tunnel host is listed. Never "whatever Host this request came with".
|
|
51
|
+
return allowedHostList().includes(u.host.toLowerCase());
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
// Which surface an alert belongs to. Operate-side events (autopilot runtime: dead-letters,
|
|
@@ -93,4 +121,4 @@ function isInsideDir(base, target) {
|
|
|
93
121
|
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
94
122
|
}
|
|
95
123
|
|
|
96
|
-
export { csvCell, originAllowed, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
|
|
124
|
+
export { csvCell, originAllowed, hostAllowed, allowedHostList, parseAllowedHosts, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
|
|
@@ -5,6 +5,7 @@ import { sseClients } from './state.mjs';
|
|
|
5
5
|
import { listProjects } from './projects.mjs';
|
|
6
6
|
import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
|
|
7
7
|
import { getPipeline, getInbox } from './data-readers.mjs';
|
|
8
|
+
import { agentActivitySince, activityStamp } from './agent-activity.mjs';
|
|
8
9
|
|
|
9
10
|
// ── File watcher ───────────────────────────────────────────────────────────────
|
|
10
11
|
function watchBeads() {
|
|
@@ -147,4 +148,36 @@ function watchVerdicts() {
|
|
|
147
148
|
} catch {}
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// ADR-021: push agent events to the clients watching the project that wrote them.
|
|
152
|
+
//
|
|
153
|
+
// Polled, not fs.watch'd: events.jsonl is created on the first event and rotated
|
|
154
|
+
// at its cap, and a watch on a file that does not exist yet — or was just renamed
|
|
155
|
+
// away — silently watches nothing. One stat per watched project per second is
|
|
156
|
+
// cheap, and it meets the ADR's "on the board within two seconds".
|
|
157
|
+
function watchAgentEvents({ intervalMs = 1000 } = {}) {
|
|
158
|
+
const stamps = new Map();
|
|
159
|
+
const timer = setInterval(() => {
|
|
160
|
+
const cwds = new Set();
|
|
161
|
+
for (const res of sseClients) cwds.add(res._gctoCwd || process.cwd());
|
|
162
|
+
for (const cwd of cwds) {
|
|
163
|
+
const stamp = activityStamp(cwd);
|
|
164
|
+
if (stamps.get(cwd) === stamp) continue;
|
|
165
|
+
stamps.set(cwd, stamp);
|
|
166
|
+
// Phase 2: each client gets what came after ITS cursor — a delta, or a snapshot
|
|
167
|
+
// when its cursor stopped pointing into this file — and the frame names the new
|
|
168
|
+
// cursor. Nothing new for a client is nothing sent.
|
|
169
|
+
for (const res of sseClients) {
|
|
170
|
+
if ((res._gctoCwd || process.cwd()) !== cwd) continue;
|
|
171
|
+
let a;
|
|
172
|
+
try { a = agentActivitySince(cwd, res._gctoAgentCursor ?? null); } catch { continue; }
|
|
173
|
+
res._gctoAgentCursor = a.cursor;
|
|
174
|
+
if (a.mode === 'delta' && a.events.length === 0) continue;
|
|
175
|
+
try { res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`); } catch { sseClients.delete(res); }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, intervalMs);
|
|
179
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
180
|
+
return timer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export { watchBeads, watchVerdicts, watchAgentEvents };
|