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.
@@ -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
+ }