waku-memory 0.2.0 → 0.3.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/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +256 -64
- package/dist/capture.js +660 -112
- package/dist/cli.js +351 -64
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +2 -2
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +103 -74
- package/dist/login.js +312 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
package/dist/hook.js
CHANGED
|
@@ -1,49 +1,69 @@
|
|
|
1
1
|
// The `hook` subcommand's decision logic. Wired by cli.ts: run() reads one
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// hook event as JSON off stdin (that part deliberately does not live here --
|
|
3
|
+
// see the note on handleHookEvent below) and calls handleHookEvent with the
|
|
4
|
+
// parsed event and a real deps bag. Everything in this file takes its
|
|
5
|
+
// inputs as plain values, so hook.test.mjs can call it directly against temp
|
|
6
|
+
// files and an injected fetchImpl, the same way cli.test.mjs drives
|
|
7
|
+
// applyToHarness without touching a real ~/.claude.json.
|
|
8
|
+
//
|
|
9
|
+
// Claude Code and Codex both land here (spec 012 §4): the event shape and
|
|
10
|
+
// the stdin fields this file reads were measured identical across the two,
|
|
11
|
+
// so this module itself never asks which harness sent an event -- deps
|
|
12
|
+
// carries a HarnessId, handleHookEvent resolves it to one HarnessProfile
|
|
13
|
+
// (harnesses.ts's PROFILES) up front, and every harness-specific choice
|
|
14
|
+
// (content format, delta formatter, timing budgets, how the brief is
|
|
15
|
+
// printed) reads off that one value. A third harness is a third profile,
|
|
16
|
+
// never a new branch in this file.
|
|
8
17
|
//
|
|
9
18
|
// This is the one piece of the shim that runs unattended, on every turn, in
|
|
10
19
|
// the middle of somebody else's tool -- see task 14's brief for the full
|
|
11
20
|
// argument. Two consequences shape every decision below:
|
|
12
21
|
//
|
|
13
22
|
// 1. It must never throw and never leave a nonzero exit behind. A capture
|
|
14
|
-
// bug must not read to the user as their actual
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
23
|
+
// bug must not read to the user as their actual session failing.
|
|
24
|
+
// handleHookEvent wraps its entire body in one try/catch for exactly
|
|
25
|
+
// this reason, and every expected failure (missing config, no new
|
|
26
|
+
// bytes, a non-2xx response, a rejected fetch) returns through its own
|
|
27
|
+
// branch before ever reaching that catch -- the catch is the net under
|
|
28
|
+
// the net, for whatever this file's author did not think of.
|
|
20
29
|
// 2. Content goes to the ingest endpoint and nowhere else, with one
|
|
21
30
|
// deliberate exception: SessionStart's postBrief writes POST /brief's
|
|
22
|
-
// `text`
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
31
|
+
// `text` (run through the harness profile's briefOutput -- identity
|
|
32
|
+
// for Claude Code, the hook JSON contract for Codex) to stdout, which
|
|
33
|
+
// the harness folds into the session's context -- that is the entire
|
|
34
|
+
// point of task 9. stdout carries the brief and nothing else, ever.
|
|
35
|
+
// Every other channel is stderr (console.error), and never carries a
|
|
36
|
+
// transcript byte, a delta, or any other response body -- only counts,
|
|
37
|
+
// status codes, and the event/session names already on stdin. The one
|
|
38
|
+
// further exception is ruling R4 (spec 012 §4.1): the Codex profile
|
|
39
|
+
// stays silent on stderr too when there is no usable credential, since
|
|
40
|
+
// Codex has no interactive onboarding to have warned the user first.
|
|
28
41
|
//
|
|
29
42
|
// Zero dependencies, per the brief: only node:fs and node:path here (plus
|
|
30
|
-
// the sibling dialogue.ts
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// site in cli.ts.
|
|
43
|
+
// the sibling dialogue.ts and harnesses.ts modules -- local files, not npm
|
|
44
|
+
// packages), plus the global fetch (Node 18+, no import needed) that
|
|
45
|
+
// deps.fetchImpl defaults to at the real call site in cli.ts.
|
|
34
46
|
import { appendFileSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
35
47
|
import { basename, dirname, join } from 'node:path';
|
|
36
|
-
import {
|
|
48
|
+
import { formatDelta, renderEntries } from "./dialogue.js";
|
|
49
|
+
import { profileFor } from "./harnesses.js";
|
|
37
50
|
import { resolveProject } from "./project.js";
|
|
51
|
+
// SESSION_END_HOOK_TIMEOUT_S/SESSION_END_TIMEOUT_MS are the Claude Code
|
|
52
|
+
// profile's numbers (harnesses.ts's PROFILES.claude_code) -- defined there,
|
|
53
|
+
// not here, to avoid an import cycle (this file imports profileFor from
|
|
54
|
+
// harnesses.ts; harnesses.ts must not import back). Re-exported unchanged so
|
|
55
|
+
// capture.ts's existing `import { ... } from './hook.ts'` keeps working. See
|
|
56
|
+
// harnesses.ts's own comment on them for the full derivation.
|
|
57
|
+
export { SESSION_END_HOOK_TIMEOUT_S, SESSION_END_TIMEOUT_MS } from "./harnesses.js";
|
|
38
58
|
// Bumped by hand at release time alongside package.json's "version" -- the
|
|
39
59
|
// brief's call: a build-time constant here is fine, and it keeps this file
|
|
40
60
|
// free of any fs reach into its own package.json (which would also be
|
|
41
61
|
// wrong at runtime, since dist/hook.js does not sit next to package.json
|
|
42
62
|
// the way src/hook.ts does). Exported since postBrief's request body and
|
|
43
63
|
// hook.test.mjs's drift check (against package.json) both need it; task 14
|
|
44
|
-
// bumps this
|
|
45
|
-
|
|
46
|
-
const
|
|
64
|
+
// (spec 012 §9) bumps this, package.json and the plugin manifest
|
|
65
|
+
// (.codex-plugin/plugin.json) to 0.3.0 together.
|
|
66
|
+
export const SHIM_VERSION = '0.3.0';
|
|
47
67
|
// Exported for capture.ts, which writes the file this module reads.
|
|
48
68
|
export const CONFIG_FILE_NAME = 'config.json';
|
|
49
69
|
// Same-directory temp file + rename, exactly atomicWriteJson's technique in
|
|
@@ -133,7 +153,14 @@ export function droppedMarkerPath(watermarkPath) {
|
|
|
133
153
|
// zero pieces -- every new line was dropped by the formatting rule -- and
|
|
134
154
|
// the caller still advances the watermark to `end` in that case, per
|
|
135
155
|
// formatDelta's own contract.
|
|
136
|
-
export function readDelta(transcriptPath, watermarkPath
|
|
156
|
+
export function readDelta(transcriptPath, watermarkPath,
|
|
157
|
+
// Which formatting rule to run past the watermark -- dialogue.ts's
|
|
158
|
+
// cc-dialogue-v1 by default (every caller before task 8, and every reader
|
|
159
|
+
// test above, wants exactly that); postDelta below passes the running
|
|
160
|
+
// event's profile.formatDelta instead, so the same watermark/piece
|
|
161
|
+
// machinery serves codex-dialogue-v1 too without a second copy of this
|
|
162
|
+
// function.
|
|
163
|
+
formatDeltaFn = formatDelta) {
|
|
137
164
|
let buf;
|
|
138
165
|
try {
|
|
139
166
|
buf = readFileSync(transcriptPath);
|
|
@@ -144,7 +171,7 @@ export function readDelta(transcriptPath, watermarkPath) {
|
|
|
144
171
|
const offset = readWatermark(watermarkPath);
|
|
145
172
|
if (offset >= buf.length)
|
|
146
173
|
return null; // nothing new since last watermark (also covers a shrunk/rotated file)
|
|
147
|
-
const { pieces, end } =
|
|
174
|
+
const { pieces, end } = formatDeltaFn(buf.subarray(offset));
|
|
148
175
|
if (end === 0)
|
|
149
176
|
return null; // the only new bytes are an incomplete trailing line
|
|
150
177
|
return { pieces: pieces.map((p) => ({ ...p, end: offset + p.end })), end: offset + end, offset };
|
|
@@ -190,27 +217,6 @@ function postJson(deps, config, path, body, signal) {
|
|
|
190
217
|
signal,
|
|
191
218
|
});
|
|
192
219
|
}
|
|
193
|
-
// The SessionEnd flush has to finish inside the hook entry capture.ts
|
|
194
|
-
// installs for it, or Claude Code kills the process mid-request with no
|
|
195
|
-
// chance to log why. That entry's `timeout` (seconds) is
|
|
196
|
-
// SESSION_END_HOOK_TIMEOUT_S, defined here so capture.ts imports it and the
|
|
197
|
-
// two numbers cannot drift apart again: until 2026-09-04 the network budget
|
|
198
|
-
// below was 800 ms, derived from Claude Code's 1.5 s *default* SessionEnd
|
|
199
|
-
// budget, while the installed entry had already raised that budget to 10 s
|
|
200
|
-
// -- and 800 ms is one cold round trip to api.waku.one from Yang's machine
|
|
201
|
-
// (three real POST /ingest/session calls measured 822, 644 and 1174 ms), so
|
|
202
|
-
// two of every three flushes were aborted by our own timer. That is the
|
|
203
|
-
// last turn of every session, and all of a single-turn one.
|
|
204
|
-
//
|
|
205
|
-
// The network budget is the process budget minus what happens before the
|
|
206
|
-
// request: `npx -y waku-memory hook` took 2.8 s to reach this code cold
|
|
207
|
-
// (2026-09-04), and Node startup, stdin and the transcript read are a few
|
|
208
|
-
// hundred milliseconds more. 5 s leaves about 2 s of the 10 s for a slower
|
|
209
|
-
// machine. hook.test.mjs pins both bounds. Stop is not bounded this way
|
|
210
|
-
// (600 s budget, and runs "async": true), so its path through postDelta
|
|
211
|
-
// below does not use this.
|
|
212
|
-
export const SESSION_END_HOOK_TIMEOUT_S = 10;
|
|
213
|
-
export const SESSION_END_TIMEOUT_MS = 5000;
|
|
214
220
|
// undici wraps every network-level failure as TypeError("fetch failed") and
|
|
215
221
|
// puts the reason on `cause` -- a DNS name, a refused connection, our own
|
|
216
222
|
// abort, or (2026-09-04) an Authorization header carrying a control
|
|
@@ -238,10 +244,15 @@ export const BRIEF_TIMEOUT_MS = 3000;
|
|
|
238
244
|
// do-nothing-and-log branches) but now bounded by an AbortController --
|
|
239
245
|
// unlike a heartbeat nobody was waiting on, a hung /brief request sits in
|
|
240
246
|
// the middle of the user's SessionStart, so this cannot wait forever.
|
|
241
|
-
async function postBrief(event, deps) {
|
|
247
|
+
async function postBrief(event, deps, profile) {
|
|
242
248
|
const config = readConfig(deps.configDir);
|
|
243
249
|
if (!config) {
|
|
244
|
-
|
|
250
|
+
// Ruling R4 (spec 012 §4.1): Codex-only, quiet without a credential --
|
|
251
|
+
// no stdout, no stderr, no fetch call. Claude Code keeps this line
|
|
252
|
+
// (spec 011 A9: its output is unchanged).
|
|
253
|
+
if (!profile.silentWithoutCredential) {
|
|
254
|
+
console.error(`waku-memory hook: no usable config at ${join(deps.configDir, CONFIG_FILE_NAME)} -- no brief.`);
|
|
255
|
+
}
|
|
245
256
|
return;
|
|
246
257
|
}
|
|
247
258
|
if (typeof event.cwd !== 'string' || event.cwd === '') {
|
|
@@ -254,15 +265,19 @@ async function postBrief(event, deps) {
|
|
|
254
265
|
const timer = setTimeout(() => controller.abort(), deps.briefTimeoutMs ?? BRIEF_TIMEOUT_MS);
|
|
255
266
|
try {
|
|
256
267
|
// POST, not GET: `project` is caller-supplied free text and a query
|
|
257
|
-
// string would put it in every access log (spec 011 §5).
|
|
258
|
-
|
|
268
|
+
// string would put it in every access log (spec 011 §5). session_id
|
|
269
|
+
// travels here too (spec 012 §4) -- every harness, not just Codex.
|
|
270
|
+
const res = await postJson(deps, config, '/brief', { harness: profile.id, version: SHIM_VERSION, project, session_id: event.session_id }, controller.signal);
|
|
259
271
|
if (!res.ok) {
|
|
260
272
|
console.error(`waku-memory hook: /brief responded ${res.status} -- no brief.`);
|
|
261
273
|
return;
|
|
262
274
|
}
|
|
263
275
|
const body = (await res.json());
|
|
264
276
|
if (typeof body.text === 'string' && body.text !== '') {
|
|
265
|
-
|
|
277
|
+
// Identity for Claude Code (plain text); Codex wraps it in the hook
|
|
278
|
+
// JSON contract (spec 012 §4) -- the only place that knows which is
|
|
279
|
+
// profile.briefOutput itself, never a check here.
|
|
280
|
+
(deps.stdout ?? defaultStdout)(profile.briefOutput(body.text));
|
|
266
281
|
}
|
|
267
282
|
else {
|
|
268
283
|
// A 2xx with missing, non-string, or empty text is a failure like any other.
|
|
@@ -302,15 +317,22 @@ export function trailingAssistantText(entries) {
|
|
|
302
317
|
.map((e) => e.text)
|
|
303
318
|
.join(' ');
|
|
304
319
|
}
|
|
305
|
-
async function postDelta(event, deps, source) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
320
|
+
async function postDelta(event, deps, source, profile) {
|
|
321
|
+
// Config is checked before transcript_path, deliberately: ruling R4 (spec
|
|
322
|
+
// 012 §4.1) says a Codex event with no usable config.json exits quietly
|
|
323
|
+
// regardless of what else about the event is missing or malformed --
|
|
324
|
+
// checking transcript_path first would let that branch's stderr line fire
|
|
325
|
+
// ahead of the credential check and break the "no stderr at all" promise.
|
|
310
326
|
const configPath = join(deps.configDir, CONFIG_FILE_NAME);
|
|
311
327
|
const config = readConfig(deps.configDir);
|
|
312
328
|
if (!config) {
|
|
313
|
-
|
|
329
|
+
if (!profile.silentWithoutCredential) {
|
|
330
|
+
console.error(`waku-memory hook: no usable config at ${configPath} -- skipping.`);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!event.transcript_path) {
|
|
335
|
+
console.error(`waku-memory hook: no transcript_path on this ${event.hook_event_name} event -- skipping.`);
|
|
314
336
|
return;
|
|
315
337
|
}
|
|
316
338
|
// session_id arrives parsed from stdin and is used as a path segment
|
|
@@ -319,7 +341,7 @@ async function postDelta(event, deps, source) {
|
|
|
319
341
|
// configDir/state. The same-user trust boundary already limits the blast
|
|
320
342
|
// radius, but this removes the footgun rather than relying on that alone.
|
|
321
343
|
const watermarkPath = join(deps.configDir, 'state', basename(event.session_id));
|
|
322
|
-
let delta = readDelta(event.transcript_path, watermarkPath);
|
|
344
|
+
let delta = readDelta(event.transcript_path, watermarkPath, profile.formatDelta);
|
|
323
345
|
if (delta === null) {
|
|
324
346
|
// spec 011 §2: the transcript can lag the turn past every complete
|
|
325
347
|
// line it holds. On Stop, a non-empty last_assistant_message is
|
|
@@ -364,14 +386,15 @@ async function postDelta(event, deps, source) {
|
|
|
364
386
|
}
|
|
365
387
|
}
|
|
366
388
|
}
|
|
367
|
-
// Only session-end is bounded -- see
|
|
368
|
-
// One deadline for the whole flush, not one budget per piece:
|
|
369
|
-
// pieces each getting the full
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
389
|
+
// Only session-end is bounded -- see harnesses.ts's SESSION_END_TIMEOUT_MS
|
|
390
|
+
// comment. One deadline for the whole flush, not one budget per piece:
|
|
391
|
+
// several pieces each getting the full budget would let a many-piece
|
|
392
|
+
// session run far longer than the budget the installed hook entry
|
|
393
|
+
// actually gives the process. Stop's deadline stays undefined, so every
|
|
394
|
+
// piece's controller below is undefined too -- identical to not passing a
|
|
395
|
+
// signal at all. profile.sessionEndTimeoutMs is Codex's 2000 ms or Claude
|
|
396
|
+
// Code's 5000 ms; deps.sessionEndTimeoutMs (test-only) overrides either.
|
|
397
|
+
const budgetMs = deps.sessionEndTimeoutMs ?? profile.sessionEndTimeoutMs;
|
|
375
398
|
const deadline = source === 'session-end' ? Date.now() + budgetMs : undefined;
|
|
376
399
|
let previousEnd = delta.offset;
|
|
377
400
|
for (const piece of delta.pieces) {
|
|
@@ -380,11 +403,11 @@ async function postDelta(event, deps, source) {
|
|
|
380
403
|
let res;
|
|
381
404
|
try {
|
|
382
405
|
res = await postJson(deps, config, '/ingest/session', {
|
|
383
|
-
harness:
|
|
406
|
+
harness: profile.id,
|
|
384
407
|
session_id: event.session_id,
|
|
385
408
|
source,
|
|
386
409
|
content: piece.text,
|
|
387
|
-
content_format:
|
|
410
|
+
content_format: profile.contentFormat,
|
|
388
411
|
...(event.cwd ? { project: resolveProject(event.cwd) } : {}),
|
|
389
412
|
}, controller?.signal);
|
|
390
413
|
}
|
|
@@ -459,15 +482,21 @@ export async function handleHookEvent(event, deps) {
|
|
|
459
482
|
console.error('waku-memory hook: received a malformed hook event on stdin -- skipping.');
|
|
460
483
|
return;
|
|
461
484
|
}
|
|
485
|
+
// The only place that asks "which harness?" -- everything downstream
|
|
486
|
+
// (postBrief, postDelta, readDelta) takes the answer as a profile, never
|
|
487
|
+
// as a string to compare against. deps.harness is typed HarnessId, so
|
|
488
|
+
// profileFor always finds an entry in PROFILES; cli.ts's dispatch() is
|
|
489
|
+
// what rejects an unknown --harness value before this is ever reached.
|
|
490
|
+
const profile = profileFor(deps.harness ?? 'claude_code');
|
|
462
491
|
switch (event.hook_event_name) {
|
|
463
492
|
case 'SessionStart':
|
|
464
|
-
await postBrief(event, deps);
|
|
493
|
+
await postBrief(event, deps, profile);
|
|
465
494
|
return;
|
|
466
495
|
case 'Stop':
|
|
467
|
-
await postDelta(event, deps, 'stop');
|
|
496
|
+
await postDelta(event, deps, 'stop', profile);
|
|
468
497
|
return;
|
|
469
498
|
case 'SessionEnd':
|
|
470
|
-
await postDelta(event, deps, 'session-end');
|
|
499
|
+
await postDelta(event, deps, 'session-end', profile);
|
|
471
500
|
return;
|
|
472
501
|
default:
|
|
473
502
|
console.error(`waku-memory hook: ignoring unhandled event "${String(event.hook_event_name)}".`);
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// `login` -- the browser OAuth flow that mints an API key without the
|
|
2
|
+
// person ever seeing one (spec 012 §7). Wired by cli.ts's `login` command
|
|
3
|
+
// and, since task 13, `capture enable`'s third path (a person who has no
|
|
4
|
+
// key yet is offered this instead of the paste prompt); this file only
|
|
5
|
+
// orchestrates the flow itself against an injected deps bag, the same
|
|
6
|
+
// split hook.ts/capture.ts already established, so login.test.mjs can
|
|
7
|
+
// drive it against a fake authorization server and a fake API on
|
|
8
|
+
// 127.0.0.1 and never touch a real browser or a real waku.one.
|
|
9
|
+
//
|
|
10
|
+
// The shape, in order: discover the authorization server from the API's
|
|
11
|
+
// protected-resource metadata, dynamically register a public client,
|
|
12
|
+
// listen on a loopback port, send the person to the authorization
|
|
13
|
+
// endpoint (PKCE S256, a random state, printed and opened), wait for the
|
|
14
|
+
// redirect back, exchange the code for a token, mint a key with it, and
|
|
15
|
+
// write that key exactly where a pasted one goes (writeConfigJson,
|
|
16
|
+
// sanitizeKey -- both capture.ts's, not reimplemented here).
|
|
17
|
+
//
|
|
18
|
+
// Zero new runtime dependency, per the shim's package.json: node:http,
|
|
19
|
+
// node:crypto, node:os and capture.ts (itself dependency-free) only.
|
|
20
|
+
import { createServer } from 'node:http';
|
|
21
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
22
|
+
import { hostname } from 'node:os';
|
|
23
|
+
import { sanitizeKey, writeConfigJson } from "./capture.js";
|
|
24
|
+
// §7: five minutes to complete a browser round trip is generous without
|
|
25
|
+
// being indefinite -- a person who wanders off leaves nothing running past
|
|
26
|
+
// that, and the loopback server closes either way (see login()'s finish).
|
|
27
|
+
export const LOGIN_TIMEOUT_MS = 300_000;
|
|
28
|
+
export const LOGIN_SCOPES = 'openid profile email offline_access';
|
|
29
|
+
// The `resource` parameter (RFC 8707) login sends in the authorize URL and
|
|
30
|
+
// the token request is always derived at runtime from deps.apiBase --
|
|
31
|
+
// `${apiBase}/mcp` with a trailing slash stripped first -- never this
|
|
32
|
+
// constant. LOGIN_RESOURCE exists only as the documented value for the
|
|
33
|
+
// default apiBase, so a reader (or a test asserting against the default)
|
|
34
|
+
// has something to compare the runtime value to without recomputing it.
|
|
35
|
+
export const LOGIN_RESOURCE = 'https://api.waku.one/mcp';
|
|
36
|
+
const REGISTERED_REDIRECT_URI = 'http://127.0.0.1/callback';
|
|
37
|
+
// Carries which of the four network steps failed and the HTTP status that
|
|
38
|
+
// said so -- login()'s refusal branches read both off this and print
|
|
39
|
+
// neither a response body nor anything from the request (never the token,
|
|
40
|
+
// never the key): "Sign-in failed at <step> (HTTP <status>)." is the whole
|
|
41
|
+
// line.
|
|
42
|
+
class StepError extends Error {
|
|
43
|
+
step;
|
|
44
|
+
status;
|
|
45
|
+
constructor(step, status) {
|
|
46
|
+
super(`${step} step failed with HTTP ${status}`);
|
|
47
|
+
this.step = step;
|
|
48
|
+
this.status = status;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function stripSlash(url) {
|
|
52
|
+
return url.replace(/\/+$/, '');
|
|
53
|
+
}
|
|
54
|
+
// GET <apiBase>/.well-known/oauth-protected-resource for the resource's
|
|
55
|
+
// authorization server, then that server's own
|
|
56
|
+
// /.well-known/oauth-authorization-server for the three endpoints login
|
|
57
|
+
// needs. Throws StepError('discovery', status) on anything short of a
|
|
58
|
+
// clean pair of 2xx JSON responses carrying the fields this reads --
|
|
59
|
+
// login() is the only caller and turns that into the one printed line.
|
|
60
|
+
export async function discover(apiBase, fetchImpl) {
|
|
61
|
+
const base = stripSlash(apiBase);
|
|
62
|
+
const resourceRes = await fetchImpl(`${base}/.well-known/oauth-protected-resource`);
|
|
63
|
+
if (!resourceRes.ok)
|
|
64
|
+
throw new StepError('discovery', resourceRes.status);
|
|
65
|
+
const resourceBody = (await resourceRes.json());
|
|
66
|
+
const issuer = Array.isArray(resourceBody.authorization_servers) ? resourceBody.authorization_servers[0] : undefined;
|
|
67
|
+
if (typeof issuer !== 'string' || issuer === '')
|
|
68
|
+
throw new StepError('discovery', resourceRes.status);
|
|
69
|
+
const asRes = await fetchImpl(`${stripSlash(issuer)}/.well-known/oauth-authorization-server`);
|
|
70
|
+
if (!asRes.ok)
|
|
71
|
+
throw new StepError('discovery', asRes.status);
|
|
72
|
+
const asBody = (await asRes.json());
|
|
73
|
+
const { authorization_endpoint, token_endpoint, registration_endpoint } = asBody;
|
|
74
|
+
if (typeof authorization_endpoint !== 'string' ||
|
|
75
|
+
typeof token_endpoint !== 'string' ||
|
|
76
|
+
typeof registration_endpoint !== 'string') {
|
|
77
|
+
throw new StepError('discovery', asRes.status);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
issuer,
|
|
81
|
+
authorizationEndpoint: authorization_endpoint,
|
|
82
|
+
tokenEndpoint: token_endpoint,
|
|
83
|
+
registrationEndpoint: registration_endpoint,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Dynamic client registration (RFC 7591): a public client, redirect fixed
|
|
87
|
+
// at the bare loopback address (RFC 8252 §7.3 -- the *port* login actually
|
|
88
|
+
// redirects to is chosen per run and never registered, which is the whole
|
|
89
|
+
// point of the loopback exception). Returns client_id; throws
|
|
90
|
+
// StepError('registration', status) otherwise.
|
|
91
|
+
export async function registerClient(registrationEndpoint, fetchImpl, version) {
|
|
92
|
+
const res = await fetchImpl(registrationEndpoint, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
client_name: `waku-memory ${version}`,
|
|
97
|
+
redirect_uris: [REGISTERED_REDIRECT_URI],
|
|
98
|
+
grant_types: ['authorization_code'],
|
|
99
|
+
response_types: ['code'],
|
|
100
|
+
token_endpoint_auth_method: 'none',
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
throw new StepError('registration', res.status);
|
|
105
|
+
const body = (await res.json());
|
|
106
|
+
if (typeof body.client_id !== 'string' || body.client_id === '')
|
|
107
|
+
throw new StepError('registration', res.status);
|
|
108
|
+
return body.client_id;
|
|
109
|
+
}
|
|
110
|
+
// PKCE (RFC 7636), S256 only: a 32-byte random verifier, base64url; the
|
|
111
|
+
// challenge is its SHA-256 digest, also base64url. Buffer's own
|
|
112
|
+
// 'base64url' encoding already omits padding, so neither value needs the
|
|
113
|
+
// usual base64 cleanup.
|
|
114
|
+
export function pkce() {
|
|
115
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
116
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
117
|
+
return { verifier, challenge };
|
|
118
|
+
}
|
|
119
|
+
export function authorizeUrl(p) {
|
|
120
|
+
const url = new URL(p.authorizationEndpoint);
|
|
121
|
+
url.searchParams.set('response_type', 'code');
|
|
122
|
+
url.searchParams.set('client_id', p.clientId);
|
|
123
|
+
url.searchParams.set('redirect_uri', p.redirectUri);
|
|
124
|
+
url.searchParams.set('state', p.state);
|
|
125
|
+
url.searchParams.set('code_challenge', p.challenge);
|
|
126
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
127
|
+
url.searchParams.set('scope', LOGIN_SCOPES);
|
|
128
|
+
url.searchParams.set('resource', p.resource);
|
|
129
|
+
return url.toString();
|
|
130
|
+
}
|
|
131
|
+
async function exchangeToken(tokenEndpoint, fetchImpl, p) {
|
|
132
|
+
const body = new URLSearchParams({
|
|
133
|
+
grant_type: 'authorization_code',
|
|
134
|
+
code: p.code,
|
|
135
|
+
redirect_uri: p.redirectUri,
|
|
136
|
+
client_id: p.clientId,
|
|
137
|
+
code_verifier: p.verifier,
|
|
138
|
+
resource: p.resource,
|
|
139
|
+
});
|
|
140
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
143
|
+
body: body.toString(),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
throw new StepError('token', res.status);
|
|
147
|
+
const json = (await res.json());
|
|
148
|
+
if (typeof json.access_token !== 'string' || json.access_token === '')
|
|
149
|
+
throw new StepError('token', res.status);
|
|
150
|
+
return { accessToken: json.access_token, idToken: typeof json.id_token === 'string' ? json.id_token : undefined };
|
|
151
|
+
}
|
|
152
|
+
// The email printed in the final "Signed in as <email>" line -- read from
|
|
153
|
+
// the token response's id_token, an unsigned decode of its middle segment
|
|
154
|
+
// (base64url JSON), never signature-checked: it arrived over TLS from the
|
|
155
|
+
// issuer discover() itself found, and the only use is one word in a
|
|
156
|
+
// stdout line, not an authorization decision. Anything short of a clean
|
|
157
|
+
// {email: string} there (no id_token, unparseable, wrong shape) falls back
|
|
158
|
+
// to the literal word "you" -- login() never blocks on this.
|
|
159
|
+
function emailFromIdToken(idToken) {
|
|
160
|
+
if (!idToken)
|
|
161
|
+
return null;
|
|
162
|
+
const parts = idToken.split('.');
|
|
163
|
+
if (parts.length < 2)
|
|
164
|
+
return null;
|
|
165
|
+
try {
|
|
166
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
167
|
+
return typeof payload.email === 'string' && payload.email !== '' ? payload.email : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// keys.py's CreateKeyRequest.label: free text, default "unnamed", capped
|
|
174
|
+
// at 200 chars server-side -- truncated here too so a very long hostname
|
|
175
|
+
// never trips the server's own validation into a refusal this file would
|
|
176
|
+
// then have to explain.
|
|
177
|
+
function keyLabel() {
|
|
178
|
+
const label = `waku-memory on ${hostname()}`;
|
|
179
|
+
return label.length > 200 ? label.slice(0, 200) : label;
|
|
180
|
+
}
|
|
181
|
+
function reportRefusal(deps, fallbackStep, err) {
|
|
182
|
+
const step = err instanceof StepError ? err.step : fallbackStep;
|
|
183
|
+
const status = err instanceof StepError ? err.status : 0;
|
|
184
|
+
deps.stdout(`Sign-in failed at ${step} (HTTP ${status}).`);
|
|
185
|
+
}
|
|
186
|
+
// The whole flow. Never prints the token or the key on any path -- the
|
|
187
|
+
// only things written to deps.stdout are the authorize URL, the final
|
|
188
|
+
// "Signed in as <email>" line, and (on refusal) the one-line step/status
|
|
189
|
+
// report above. The loopback server, once opened, is closed on every path
|
|
190
|
+
// out of the returned promise: 'signed-in', 'timeout', and 'refused' from
|
|
191
|
+
// a bad token or keys response all go through the same finish().
|
|
192
|
+
// 'refused' from discovery or registration returns before any server
|
|
193
|
+
// exists, so there is nothing to close on those two paths.
|
|
194
|
+
export async function login(deps) {
|
|
195
|
+
const apiBase = stripSlash(deps.apiBase);
|
|
196
|
+
const resource = `${apiBase}/mcp`;
|
|
197
|
+
const timeoutMs = deps.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
198
|
+
let discovered;
|
|
199
|
+
try {
|
|
200
|
+
discovered = await discover(apiBase, deps.fetchImpl);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
reportRefusal(deps, 'discovery', err);
|
|
204
|
+
return 'refused';
|
|
205
|
+
}
|
|
206
|
+
let clientId;
|
|
207
|
+
try {
|
|
208
|
+
clientId = await registerClient(discovered.registrationEndpoint, deps.fetchImpl, deps.version);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
reportRefusal(deps, 'registration', err);
|
|
212
|
+
return 'refused';
|
|
213
|
+
}
|
|
214
|
+
const { verifier, challenge } = pkce();
|
|
215
|
+
const state = randomBytes(16).toString('hex');
|
|
216
|
+
return new Promise((resolveLogin) => {
|
|
217
|
+
let settled = false;
|
|
218
|
+
let port = 0;
|
|
219
|
+
let accepted = false;
|
|
220
|
+
const server = createServer((req, res) => {
|
|
221
|
+
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
222
|
+
if (requestUrl.pathname !== '/callback') {
|
|
223
|
+
res.writeHead(404);
|
|
224
|
+
res.end();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const code = requestUrl.searchParams.get('code');
|
|
228
|
+
const receivedState = requestUrl.searchParams.get('state');
|
|
229
|
+
if (receivedState !== state || !code) {
|
|
230
|
+
// Wrong or missing state: keep waiting -- a stray or forged hit on
|
|
231
|
+
// this port must not end the flow the real browser is still
|
|
232
|
+
// carrying.
|
|
233
|
+
res.writeHead(400);
|
|
234
|
+
res.end();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// OAuth codes are single-use and minting a credential should be
|
|
238
|
+
// idempotent only by accident; the client must not rely on the
|
|
239
|
+
// authorization server to defend against replay. Guard here: flag
|
|
240
|
+
// the code accepted before any async work, so a concurrent request
|
|
241
|
+
// sees the flag and returns 200 without re-running the exchange.
|
|
242
|
+
if (accepted) {
|
|
243
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
244
|
+
res.end('Signed in. You can close this tab.');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
accepted = true;
|
|
248
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
249
|
+
res.end('Signed in. You can close this tab.');
|
|
250
|
+
void finishSignIn(code);
|
|
251
|
+
});
|
|
252
|
+
const finish = (result) => {
|
|
253
|
+
if (settled)
|
|
254
|
+
return;
|
|
255
|
+
settled = true;
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
server.close();
|
|
258
|
+
resolveLogin(result);
|
|
259
|
+
};
|
|
260
|
+
async function finishSignIn(code) {
|
|
261
|
+
try {
|
|
262
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
263
|
+
const tokenResult = await exchangeToken(discovered.tokenEndpoint, deps.fetchImpl, {
|
|
264
|
+
code,
|
|
265
|
+
redirectUri,
|
|
266
|
+
clientId,
|
|
267
|
+
verifier,
|
|
268
|
+
resource,
|
|
269
|
+
});
|
|
270
|
+
const keysRes = await deps.fetchImpl(`${apiBase}/keys`, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: {
|
|
273
|
+
'Content-Type': 'application/json',
|
|
274
|
+
Authorization: `Bearer ${tokenResult.accessToken}`,
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify({ label: keyLabel() }),
|
|
277
|
+
});
|
|
278
|
+
if (!keysRes.ok)
|
|
279
|
+
throw new StepError('keys', keysRes.status);
|
|
280
|
+
const keysBody = (await keysRes.json());
|
|
281
|
+
if (typeof keysBody.plaintext !== 'string' || keysBody.plaintext === '') {
|
|
282
|
+
throw new StepError('keys', keysRes.status);
|
|
283
|
+
}
|
|
284
|
+
const { key } = sanitizeKey(keysBody.plaintext);
|
|
285
|
+
writeConfigJson(deps.configDir, { url: apiBase, key });
|
|
286
|
+
const email = emailFromIdToken(tokenResult.idToken) ?? 'you';
|
|
287
|
+
deps.stdout(`Signed in as ${email}. Key stored in ${deps.configDir}/config.json.`);
|
|
288
|
+
finish('signed-in');
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
reportRefusal(deps, 'token', err);
|
|
292
|
+
finish('refused');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const timer = setTimeout(() => finish('timeout'), timeoutMs);
|
|
296
|
+
server.listen(0, '127.0.0.1', () => {
|
|
297
|
+
const address = server.address();
|
|
298
|
+
port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
299
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
300
|
+
const url = authorizeUrl({
|
|
301
|
+
authorizationEndpoint: discovered.authorizationEndpoint,
|
|
302
|
+
clientId,
|
|
303
|
+
redirectUri,
|
|
304
|
+
state,
|
|
305
|
+
challenge,
|
|
306
|
+
resource,
|
|
307
|
+
});
|
|
308
|
+
deps.stdout(`Open this URL if your browser did not: ${url}`);
|
|
309
|
+
deps.openBrowser(url);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|