cawdev-cli 0.9.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/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Signing in — R81, and the reason this file exists is what it does NOT do.
|
|
2
|
+
//
|
|
3
|
+
// There is no password field here. The CLI asks the platform for a code, opens
|
|
4
|
+
// R55's sign-in page in a browser, and waits until a person approves it. A
|
|
5
|
+
// password belongs on a page a browser has told you the origin of; typing one
|
|
6
|
+
// into a full-screen ANSI app is asking somebody to trust a rendering.
|
|
7
|
+
//
|
|
8
|
+
// What comes back is a PERSON'S SESSION, and person-actions keep going over
|
|
9
|
+
// HTTP with it. R52's split is untouched: the socket says what this machine is
|
|
10
|
+
// doing, and anything that CHANGES something is you.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { hostname, userInfo } from 'node:os';
|
|
14
|
+
import { loadSession, saveSession } from './session-store.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A cookie jar and the CSRF dance, in the smallest form that works.
|
|
18
|
+
*
|
|
19
|
+
* The API uses a session cookie plus a double-submit CSRF cookie, because it
|
|
20
|
+
* was built for a browser. A client that is not a browser has to do by hand
|
|
21
|
+
* what Angular's HttpClient does for free: read `XSRF-TOKEN` and echo it back
|
|
22
|
+
* as `X-XSRF-TOKEN`.
|
|
23
|
+
*/
|
|
24
|
+
export class Session {
|
|
25
|
+
constructor(url) {
|
|
26
|
+
this.url = String(url).replace(/\/+$/, '');
|
|
27
|
+
this.cookies = new Map();
|
|
28
|
+
this.email = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get signedIn() {
|
|
32
|
+
return this.email !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#cookieHeader() {
|
|
36
|
+
return [...this.cookies].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#remember(response) {
|
|
40
|
+
// Node exposes repeated Set-Cookie headers through getSetCookie().
|
|
41
|
+
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
|
42
|
+
const [pair] = cookie.split(';');
|
|
43
|
+
const at = pair.indexOf('=');
|
|
44
|
+
if (at > 0) {
|
|
45
|
+
this.cookies.set(pair.slice(0, at).trim(), pair.slice(at + 1).trim());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async request(path, { method = 'GET', body } = {}) {
|
|
51
|
+
const response = await fetch(`${this.url}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers: {
|
|
54
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
55
|
+
...(this.cookies.size ? { cookie: this.#cookieHeader() } : {}),
|
|
56
|
+
// Sent on every write. The server checks it against the cookie; a
|
|
57
|
+
// missing one is a 403 that looks exactly like "wrong password".
|
|
58
|
+
...(this.cookies.has('XSRF-TOKEN')
|
|
59
|
+
? { 'x-xsrf-token': this.cookies.get('XSRF-TOKEN') }
|
|
60
|
+
: {}),
|
|
61
|
+
},
|
|
62
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
63
|
+
});
|
|
64
|
+
this.#remember(response);
|
|
65
|
+
const text = await response.text();
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
|
|
68
|
+
failure.status = response.status;
|
|
69
|
+
throw failure;
|
|
70
|
+
}
|
|
71
|
+
return text ? JSON.parse(text) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One open GET, purely to be handed the CSRF cookie before the first POST. */
|
|
75
|
+
async prime() {
|
|
76
|
+
await this.request('/api/health').catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether the stored cookies still mean anything. */
|
|
80
|
+
async resume() {
|
|
81
|
+
try {
|
|
82
|
+
const me = await this.request('/api/auth/me');
|
|
83
|
+
// `?? null`, because `signedIn` is `email !== null` and an answer with no
|
|
84
|
+
// email would set it to `undefined` — which passes that test. Nothing on
|
|
85
|
+
// the real API answers 200 without one; a proxy in front of it might, and
|
|
86
|
+
// R93's walk now takes its FIRST decision off this flag.
|
|
87
|
+
this.email = me?.email ?? null;
|
|
88
|
+
return this.email !== null;
|
|
89
|
+
} catch {
|
|
90
|
+
this.email = null;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async signOut() {
|
|
96
|
+
await this.request('/api/auth/logout', { method: 'POST' }).catch(() => undefined);
|
|
97
|
+
this.cookies.clear();
|
|
98
|
+
this.email = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function messageIn(text) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(text).message ?? null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The session stored for this instance, if there is one and it still works.
|
|
112
|
+
*
|
|
113
|
+
* A stored cookie that the platform no longer honours is not an error worth
|
|
114
|
+
* saying anything about — it is what an expired session looks like, and the
|
|
115
|
+
* answer is the same as having none.
|
|
116
|
+
*/
|
|
117
|
+
export async function storedSession(url) {
|
|
118
|
+
const session = new Session(url);
|
|
119
|
+
const stored = await loadSession(session.url);
|
|
120
|
+
if (!stored) {
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
session.cookies = stored.cookies;
|
|
124
|
+
await session.resume();
|
|
125
|
+
return session;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How this machine describes itself on the approval page.
|
|
130
|
+
*
|
|
131
|
+
* Display text, and the page treats it as such. It is here so the person
|
|
132
|
+
* approving has something to compare against the terminal in front of them.
|
|
133
|
+
*/
|
|
134
|
+
export function describeThisMachine() {
|
|
135
|
+
try {
|
|
136
|
+
return `${userInfo().username}@${hostname()}`;
|
|
137
|
+
} catch {
|
|
138
|
+
return hostname();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sign in through the browser, and remember it.
|
|
144
|
+
*
|
|
145
|
+
* @param session the jar to fill
|
|
146
|
+
* @param say where to print the code and the URL — the caller owns the screen
|
|
147
|
+
* @param open false to print the URL without launching anything
|
|
148
|
+
*/
|
|
149
|
+
export async function signInThroughBrowser(session, say, { open = true } = {}) {
|
|
150
|
+
await session.prime();
|
|
151
|
+
const started = await session.request('/api/auth/cli/start', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body: { client: describeThisMachine() },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const url = `${session.url}${started.verifyPath}`;
|
|
157
|
+
say({ kind: 'opening', url, code: started.code });
|
|
158
|
+
if (open) {
|
|
159
|
+
openInBrowser(url);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const deadline = new Date(started.expiresAt).getTime();
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
await new Promise((done) => setTimeout(done, 1500));
|
|
165
|
+
let answer;
|
|
166
|
+
try {
|
|
167
|
+
answer = await session.request('/api/auth/cli/collect', {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: { code: started.code, secret: started.secret },
|
|
170
|
+
});
|
|
171
|
+
} catch (failure) {
|
|
172
|
+
// A 404 is the grant having expired or been collected already; anything
|
|
173
|
+
// else is the platform having a moment and is worth another poll.
|
|
174
|
+
if (failure.status === 404) break;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (answer.status === 'APPROVED') {
|
|
178
|
+
session.email = answer.user.email;
|
|
179
|
+
await saveSession(session.url, { email: session.email, cookies: session.cookies });
|
|
180
|
+
return { signedIn: true, email: session.email };
|
|
181
|
+
}
|
|
182
|
+
if (answer.status === 'REFUSED') {
|
|
183
|
+
return { signedIn: false, refused: true };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { signedIn: false, expired: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Hand the URL to whatever the desktop uses.
|
|
191
|
+
*
|
|
192
|
+
* Detached and silenced, because the browser's own stderr has no business in
|
|
193
|
+
* the middle of a transcript. A failure here is not fatal and is not even
|
|
194
|
+
* reported: the URL has already been printed, and "open failed" on a machine
|
|
195
|
+
* with no desktop is noise about something that was never going to work.
|
|
196
|
+
*/
|
|
197
|
+
export function openInBrowser(url) {
|
|
198
|
+
const [command, args] = process.platform === 'darwin'
|
|
199
|
+
? ['open', [url]]
|
|
200
|
+
: process.platform === 'win32'
|
|
201
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
202
|
+
: ['xdg-open', [url]];
|
|
203
|
+
try {
|
|
204
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
205
|
+
child.on('error', () => undefined);
|
|
206
|
+
child.unref();
|
|
207
|
+
} catch {
|
|
208
|
+
// Printed already.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A stand-in for `claude`, for exercising the runner without spending anybody's
|
|
3
|
+
// Claude usage.
|
|
4
|
+
//
|
|
5
|
+
// Point the runner at it with an ABSOLUTE path and no args:
|
|
6
|
+
//
|
|
7
|
+
// { "agentCommand": "/…/tools/runner/stub-agent.mjs", "agentArgs": [] }
|
|
8
|
+
//
|
|
9
|
+
// Not `node` with the script as an argument: the runner puts `--mcp-config`
|
|
10
|
+
// first (see runner.mjs), so node would be handed a flag it does not know and
|
|
11
|
+
// exit with "bad option". The shebang runs it instead, and the cawdev flags
|
|
12
|
+
// land in its argv where it ignores them — which is exactly what `claude` does
|
|
13
|
+
// with the ones it does not need either.
|
|
14
|
+
//
|
|
15
|
+
// Absolute, because the child's cwd is the working copy, not this repository.
|
|
16
|
+
//
|
|
17
|
+
// It behaves like the real thing in the ways that matter to the runner: it is
|
|
18
|
+
// spawned in the working copy with CAWDEV_TOKEN in its environment, it talks to
|
|
19
|
+
// the platform through the same MCP tools, and — since the stage walk hung on
|
|
20
|
+
// exactly this — it does NOT exit when its turn ends. What it does *not* do is
|
|
21
|
+
// think: it follows a fixed script, which is exactly what a test wants.
|
|
22
|
+
//
|
|
23
|
+
// That last point used to read "and it exits", and the claim was the problem.
|
|
24
|
+
// The real CLI is spawned with `--input-format stream-json` and its stdin held
|
|
25
|
+
// open (R22), so when a turn finishes it sits there waiting for the next one
|
|
26
|
+
// and leaves only when its input closes. A stub that called process.exit()
|
|
27
|
+
// instead modelled away the single fact the daemon's stage walk depends on, and
|
|
28
|
+
// a walk that waited for a process which was never going to leave looked
|
|
29
|
+
// perfectly healthy against it. So: say the turn is over, then wait to be
|
|
30
|
+
// dismissed. CAWDEV_STUB_EXIT=1 restores the old, less honest behaviour for
|
|
31
|
+
// anything that genuinely wants a one-shot process.
|
|
32
|
+
//
|
|
33
|
+
// The script is chosen by CAWDEV_STUB_SCRIPT:
|
|
34
|
+
// report-and-finish (default) progress, then done
|
|
35
|
+
// ask-then-finish ask a question, wait for the answer, then done
|
|
36
|
+
// permission-then-finish ask permission for a command, wait, then done
|
|
37
|
+
// crash exit non-zero without reporting
|
|
38
|
+
// hang never say anything, for testing cancellation
|
|
39
|
+
// usage-limit say the window is closed, end the turn with
|
|
40
|
+
// SUCCESS, and then LINGER — which is what the
|
|
41
|
+
// real CLI did, and the reason a limited run sat
|
|
42
|
+
// RUNNING for forty-three minutes holding the
|
|
43
|
+
// machine's only slot
|
|
44
|
+
//
|
|
45
|
+
// It announces a session id on `init`, like the real CLI, so R69's resume path
|
|
46
|
+
// has something to record and hand back.
|
|
47
|
+
|
|
48
|
+
const url = (process.env.CAWDEV_URL ?? 'http://localhost:4200').replace(/\/+$/, '');
|
|
49
|
+
const token = process.env.CAWDEV_TOKEN;
|
|
50
|
+
const project = process.env.CAWDEV_PROJECT;
|
|
51
|
+
const script = process.env.CAWDEV_STUB_SCRIPT ?? 'report-and-finish';
|
|
52
|
+
|
|
53
|
+
// The real CLI leaves when its input closes, and not before. Modelling that is
|
|
54
|
+
// the whole reason this file is not three lines shorter.
|
|
55
|
+
function leave(code = 0) {
|
|
56
|
+
if (code !== 0 || process.env.CAWDEV_STUB_EXIT) {
|
|
57
|
+
process.exit(code);
|
|
58
|
+
}
|
|
59
|
+
process.stdin.on('end', () => process.exit(0));
|
|
60
|
+
process.stdin.on('close', () => process.exit(0));
|
|
61
|
+
process.stdin.resume();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function say(text) {
|
|
65
|
+
// The real CLI emits stream-json; the runner only reads `result` events, so
|
|
66
|
+
// this is enough to look like one.
|
|
67
|
+
process.stdout.write(`${JSON.stringify({ type: 'result', result: text })}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// R69. The real CLI announces its session id on `init`, and that is the handle
|
|
71
|
+
// `--resume` takes — so the stub announces one too, or the whole resume path is
|
|
72
|
+
// untested by anything that runs against the stub.
|
|
73
|
+
//
|
|
74
|
+
// A resumed stub keeps the id it was resumed with. The real CLI is free to hand
|
|
75
|
+
// back a different one, which the runner reports either way; keeping it here
|
|
76
|
+
// makes the stub's own script readable, and the runner's "always report the
|
|
77
|
+
// latest" rule is exercised by the platform's tests rather than guessed at.
|
|
78
|
+
const resumed = process.argv[process.argv.indexOf('--resume') + 1];
|
|
79
|
+
const sessionId =
|
|
80
|
+
process.argv.includes('--resume') && resumed ? resumed : `stub-${Date.now().toString(36)}`;
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
`${JSON.stringify({
|
|
83
|
+
type: 'system',
|
|
84
|
+
subtype: 'init',
|
|
85
|
+
session_id: sessionId,
|
|
86
|
+
model: 'stub',
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
})}\n`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
async function api(path, { method = 'GET', body } = {}) {
|
|
92
|
+
const response = await fetch(`${url}${path}`, {
|
|
93
|
+
method,
|
|
94
|
+
headers: {
|
|
95
|
+
authorization: `Bearer ${token}`,
|
|
96
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
97
|
+
},
|
|
98
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
99
|
+
});
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(`${method} ${path} -> ${response.status}: ${text}`);
|
|
103
|
+
}
|
|
104
|
+
return text ? JSON.parse(text) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const identity = await api('/api/agent/whoami');
|
|
108
|
+
const runId = identity.runId;
|
|
109
|
+
if (!runId) {
|
|
110
|
+
console.error('stub-agent: no run on this token');
|
|
111
|
+
process.exit(2);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const report = (kind, body) =>
|
|
115
|
+
api(`/api/projects/${project}/runs/${runId}/messages`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body: { kind, body },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (script === 'crash') {
|
|
121
|
+
say('about to crash');
|
|
122
|
+
process.exit(3);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (script === 'hang') {
|
|
126
|
+
say('hanging around');
|
|
127
|
+
// Long enough for a cancellation test, and harmless if one never comes.
|
|
128
|
+
setTimeout(() => process.exit(0), 10 * 60 * 1000);
|
|
129
|
+
} else if (script === 'usage-limit') {
|
|
130
|
+
// The exact sentence, middle dot and all, because that is what made the
|
|
131
|
+
// matcher's earlier patterns miss it. And then it STAYS — no exit, no error
|
|
132
|
+
// code, nothing for a close handler to read. A stub that exited here would
|
|
133
|
+
// model away the one fact that made this a forty-three minute hang rather
|
|
134
|
+
// than a run that ended badly.
|
|
135
|
+
say("You've hit your session limit · resets 4am (Africa/Tunis)");
|
|
136
|
+
setTimeout(() => process.exit(0), 10 * 60 * 1000);
|
|
137
|
+
} else {
|
|
138
|
+
await report('PROGRESS', `Stub agent, script "${script}", in ${process.cwd()}.`);
|
|
139
|
+
|
|
140
|
+
if (script === 'ask-then-finish') {
|
|
141
|
+
const question = await api(`/api/projects/${project}/runs/${runId}/questions`, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
body: { question: 'Stub agent asking: shall I carry on?', options: ['Yes', 'No'] },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// The same wait the MCP server does, in miniature.
|
|
147
|
+
let answer = null;
|
|
148
|
+
const deadline = Date.now() + 60_000;
|
|
149
|
+
while (!answer && Date.now() < deadline) {
|
|
150
|
+
const response = await fetch(
|
|
151
|
+
`${url}/api/projects/${project}/runs/${runId}/questions/${question.id}/answer?wait=5`,
|
|
152
|
+
{ headers: { authorization: `Bearer ${token}` } },
|
|
153
|
+
);
|
|
154
|
+
if (response.status === 200) {
|
|
155
|
+
answer = (await response.json()).answer;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!answer) {
|
|
159
|
+
await report('BLOCKED', 'Nobody answered the stub agent.');
|
|
160
|
+
say('nobody answered');
|
|
161
|
+
// Straight out, unlike the finish below: this is the stub giving up, and
|
|
162
|
+
// there is nothing after it worth staying open for.
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
await report('PROGRESS', `Got the answer: ${answer}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (script === 'permission-then-finish') {
|
|
169
|
+
// R51's loop, in miniature: the real CLI does this through
|
|
170
|
+
// --permission-prompt-tool, which calls the MCP server, which calls these
|
|
171
|
+
// same two endpoints. Exercising it here costs nothing and is the only way
|
|
172
|
+
// to test the whole path — inbox, decision, release — without a live
|
|
173
|
+
// model deciding for itself that it does not need Maven after all.
|
|
174
|
+
const asked = await api(`/api/projects/${project}/runs/${runId}/approvals`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: {
|
|
177
|
+
toolName: 'Bash',
|
|
178
|
+
toolInput: JSON.stringify({ command: 'mvn --version' }),
|
|
179
|
+
summary: 'mvn --version',
|
|
180
|
+
suggestion: 'Bash(mvn *)',
|
|
181
|
+
toolUseId: 'stub-tool-use',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
let decision = null;
|
|
186
|
+
const until = Date.now() + 60_000;
|
|
187
|
+
while (!decision && Date.now() < until) {
|
|
188
|
+
const response = await fetch(
|
|
189
|
+
`${url}/api/projects/${project}/runs/${runId}/approvals/${asked.id}/decision?wait=5`,
|
|
190
|
+
{ headers: { authorization: `Bearer ${token}` } },
|
|
191
|
+
);
|
|
192
|
+
if (response.status === 200) {
|
|
193
|
+
decision = await response.json();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!decision) {
|
|
197
|
+
await report('BLOCKED', 'Nobody decided the stub agent\'s permission request.');
|
|
198
|
+
say('nobody decided');
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
if (decision.state !== 'ALLOWED') {
|
|
202
|
+
await report('BLOCKED', `Refused (${decision.state}): ${decision.reason ?? 'no reason given'}`);
|
|
203
|
+
say('refused');
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
await report('PROGRESS', 'Allowed. Pretending to run mvn --version.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await report('DONE', 'Stub agent finished. No code was written, which is the point.');
|
|
210
|
+
say('done');
|
|
211
|
+
leave();
|
|
212
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Where this machine keeps its runner token — and why it is not in the config.
|
|
2
|
+
//
|
|
3
|
+
// R93 promised that no token is ever typed. It kept that promise for a machine
|
|
4
|
+
// with NO config, by writing one into the config it generated. A machine that
|
|
5
|
+
// already HAD a config fell through: `cawdev` started a daemon, `readConfig`
|
|
6
|
+
// refused to boot, and the log said "mint one in the console under Agent
|
|
7
|
+
// tokens" — the hand-carried secret R93 exists to abolish, reached by a
|
|
8
|
+
// different door.
|
|
9
|
+
//
|
|
10
|
+
// **The fix cannot be "write it into whichever config we found."** A config
|
|
11
|
+
// names working copies and permissions, so it is the kind of file people keep
|
|
12
|
+
// beside the code and commit — `tools/runner/macbook-laptop.json` in this very
|
|
13
|
+
// repository is tracked. Putting a credential in one is how a
|
|
14
|
+
// `runner:operate` token ends up in a git history, and CLAUDE.md's rule that
|
|
15
|
+
// secrets are gitignored is not a rule about `.env` in particular.
|
|
16
|
+
//
|
|
17
|
+
// So the credential lives on its own, here, the way `session.json` does and for
|
|
18
|
+
// the same reason: what a person is and what a machine may do are two
|
|
19
|
+
// different secrets with two different lifetimes, and R52's rule that a run's
|
|
20
|
+
// credential can never be borrowed for a decision is exactly why they are not
|
|
21
|
+
// one file.
|
|
22
|
+
//
|
|
23
|
+
// Keyed by URL, because one machine can point at more than one cawdev and a
|
|
24
|
+
// token minted against one instance is not a credential at another.
|
|
25
|
+
//
|
|
26
|
+
// A token IN a config still works and is read first — R93's own generated file
|
|
27
|
+
// is that shape, it is written 0600 under `~/.cawdev`, and breaking every
|
|
28
|
+
// machine set up before this would be a poor way to remove some friction.
|
|
29
|
+
//
|
|
30
|
+
// Zero dependencies, like everything in tools/.
|
|
31
|
+
|
|
32
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
|
|
36
|
+
/** Beside the session and the sockets: one directory a person can delete. */
|
|
37
|
+
export function tokenFile() {
|
|
38
|
+
return join(homedir(), '.cawdev', 'token.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function key(url) {
|
|
42
|
+
return String(url).replace(/\/+$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readAll() {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(await readFile(tokenFile(), 'utf8'));
|
|
48
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
49
|
+
} catch {
|
|
50
|
+
// No file, unreadable, or half-written. The answer to all three is the
|
|
51
|
+
// same — this machine has not minted one yet — and that is a state
|
|
52
|
+
// `cawdev` knows how to leave.
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The token stored for one instance, or null. */
|
|
58
|
+
export async function loadToken(url) {
|
|
59
|
+
const stored = (await readAll())[key(url)];
|
|
60
|
+
return typeof stored?.token === 'string' && stored.token ? stored.token : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The instances this machine holds a token for.
|
|
65
|
+
*
|
|
66
|
+
* For one message and it earns its place there: a token filed under a URL the
|
|
67
|
+
* daemon does not use is invisible, and "no token for X" with no further help
|
|
68
|
+
* sends somebody to mint a second one that lands in the same wrong place.
|
|
69
|
+
*/
|
|
70
|
+
export async function storedUrls() {
|
|
71
|
+
return Object.keys(await readAll());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Remember the token this machine minted for itself.
|
|
76
|
+
*
|
|
77
|
+
* 0700 on the directory and 0600 forced **after** the write, because
|
|
78
|
+
* `writeFile`'s mode is masked by the umask and is ignored outright for a file
|
|
79
|
+
* that already exists — so asking for 0600 and getting 0644 is the ordinary
|
|
80
|
+
* outcome rather than the unusual one.
|
|
81
|
+
*/
|
|
82
|
+
export async function saveToken(url, token, { name = null } = {}) {
|
|
83
|
+
const all = await readAll();
|
|
84
|
+
all[key(url)] = { token, name, mintedAt: new Date().toISOString() };
|
|
85
|
+
await mkdir(dirname(tokenFile()), { recursive: true, mode: 0o700 });
|
|
86
|
+
await writeFile(tokenFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
87
|
+
await chmod(tokenFile(), 0o600).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Forget one instance's token.
|
|
92
|
+
*
|
|
93
|
+
* This does NOT revoke it — the platform is where a token dies, and a CLI that
|
|
94
|
+
* pretended otherwise would leave a live credential behind a reassuring
|
|
95
|
+
* message. Removing the file is removing this machine's copy, and the caller
|
|
96
|
+
* says so.
|
|
97
|
+
*/
|
|
98
|
+
export async function clearToken(url) {
|
|
99
|
+
const all = await readAll();
|
|
100
|
+
if (!(key(url) in all)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
delete all[key(url)];
|
|
104
|
+
await mkdir(dirname(tokenFile()), { recursive: true, mode: 0o700 });
|
|
105
|
+
await writeFile(tokenFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
106
|
+
await chmod(tokenFile(), 0o600).catch(() => undefined);
|
|
107
|
+
}
|