@crouton-kit/crouter 0.3.34 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +10 -5
  2. package/dist/clients/attach/attach-cmd.js +732 -704
  3. package/dist/core/__tests__/fixtures/fake-engine.js +18 -8
  4. package/dist/core/__tests__/flagship-lifecycle.test.js +4 -1
  5. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +1 -1
  6. package/dist/core/__tests__/full/detach-focus.test.js +15 -1
  7. package/dist/core/__tests__/live-mutation-verbs.test.js +8 -2
  8. package/dist/core/__tests__/live-mutation.test.js +7 -2
  9. package/dist/core/hearth/config.js +3 -6
  10. package/dist/core/hearth/index.d.ts +0 -1
  11. package/dist/core/hearth/index.js +0 -1
  12. package/dist/core/hearth/model-auth-guest.js +9 -14
  13. package/dist/core/hearth/providers/blaxel.js +3 -3
  14. package/dist/core/hearth/types.d.ts +0 -1
  15. package/dist/web-client/assets/index-DUThOUzU.css +2 -0
  16. package/dist/web-client/assets/index-IAJVtuVe.js +80 -0
  17. package/dist/web-client/index.html +2 -2
  18. package/package.json +2 -3
  19. package/dist/core/hearth/registry.d.ts +0 -15
  20. package/dist/core/hearth/registry.js +0 -180
  21. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.d.ts +0 -1
  22. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.js +0 -289
  23. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.d.ts +0 -1
  24. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.js +0 -34
  25. package/dist/hearth/wake-proxy/__tests__/guest-source.test.d.ts +0 -1
  26. package/dist/hearth/wake-proxy/__tests__/guest-source.test.js +0 -203
  27. package/dist/hearth/wake-proxy/__tests__/hardening.test.d.ts +0 -1
  28. package/dist/hearth/wake-proxy/__tests__/hardening.test.js +0 -59
  29. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.d.ts +0 -1
  30. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.js +0 -372
  31. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.d.ts +0 -1
  32. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.js +0 -258
  33. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.d.ts +0 -1
  34. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.js +0 -437
  35. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.d.ts +0 -1
  36. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.js +0 -15
  37. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.d.ts +0 -1
  38. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.js +0 -141
  39. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.d.ts +0 -1
  40. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.js +0 -143
  41. package/dist/hearth/wake-proxy/anthropic-oauth.d.ts +0 -58
  42. package/dist/hearth/wake-proxy/anthropic-oauth.js +0 -189
  43. package/dist/hearth/wake-proxy/auth.d.ts +0 -22
  44. package/dist/hearth/wake-proxy/auth.js +0 -128
  45. package/dist/hearth/wake-proxy/config.d.ts +0 -2
  46. package/dist/hearth/wake-proxy/config.js +0 -151
  47. package/dist/hearth/wake-proxy/guest-source.d.ts +0 -14
  48. package/dist/hearth/wake-proxy/guest-source.js +0 -130
  49. package/dist/hearth/wake-proxy/hearth-status.d.ts +0 -44
  50. package/dist/hearth/wake-proxy/hearth-status.js +0 -267
  51. package/dist/hearth/wake-proxy/home.d.ts +0 -61
  52. package/dist/hearth/wake-proxy/home.js +0 -333
  53. package/dist/hearth/wake-proxy/main.d.ts +0 -1
  54. package/dist/hearth/wake-proxy/main.js +0 -134
  55. package/dist/hearth/wake-proxy/model-auth.d.ts +0 -13
  56. package/dist/hearth/wake-proxy/model-auth.js +0 -345
  57. package/dist/hearth/wake-proxy/proxy.d.ts +0 -35
  58. package/dist/hearth/wake-proxy/proxy.js +0 -716
  59. package/dist/hearth/wake-proxy/public-source-gate.d.ts +0 -9
  60. package/dist/hearth/wake-proxy/public-source-gate.js +0 -409
  61. package/dist/hearth/wake-proxy/redact.d.ts +0 -1
  62. package/dist/hearth/wake-proxy/redact.js +0 -17
  63. package/dist/hearth/wake-proxy/server.d.ts +0 -11
  64. package/dist/hearth/wake-proxy/server.js +0 -142
  65. package/dist/hearth/wake-proxy/state.d.ts +0 -18
  66. package/dist/hearth/wake-proxy/state.js +0 -342
  67. package/dist/hearth/wake-proxy/types.d.ts +0 -76
  68. package/dist/hearth/wake-proxy/types.js +0 -1
  69. package/dist/web-client/assets/index-BRKxe-hy.js +0 -80
  70. package/dist/web-client/assets/index-BZUxTkv5.css +0 -2
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="color-scheme" content="dark light" />
7
7
  <title>crouter</title>
8
- <script type="module" crossorigin src="/assets/index-BRKxe-hy.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BZUxTkv5.css">
8
+ <script type="module" crossorigin src="/assets/index-IAJVtuVe.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DUThOUzU.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.34",
3
+ "version": "0.3.35",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,7 +34,6 @@
34
34
  "lint:web-px": "node scripts/lint-web-px.mjs",
35
35
  "build:attach": "esbuild src/clients/attach/attach-cmd.ts --bundle --minify --format=esm --platform=node --target=node22 --alias:@earendil-works/pi-tui=$PWD/node_modules/@earendil-works/pi-tui --outfile=dist/clients/attach/attach-cmd.js --log-level=warning --banner:js=\"import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);\"",
36
36
  "postinstall": "node scripts/postinstall.mjs",
37
- "hearth:wake-proxy": "node dist/hearth/wake-proxy/main.js",
38
37
  "hearth:control-plane": "node dist/hearth/control-plane/main.js",
39
38
  "dev": "tsx src/cli.ts",
40
39
  "link": "npm link",
@@ -49,7 +48,7 @@
49
48
  "license": "MIT",
50
49
  "dependencies": {
51
50
  "@blaxel/core": "^0.2.94",
52
- "@crouton-kit/humanloop": "latest",
51
+ "@crouton-kit/humanloop": "^0.3.20",
53
52
  "@earendil-works/pi-agent-core": "0.80.2",
54
53
  "@earendil-works/pi-ai": "0.80.2",
55
54
  "@earendil-works/pi-coding-agent": "0.80.2",
@@ -1,15 +0,0 @@
1
- import type { HomeRecord, TenantId } from './types.js';
2
- export type RegistryData = Record<TenantId, HomeRecord>;
3
- export declare class RegistryLockError extends Error {
4
- readonly lockPath: string;
5
- readonly holder: string;
6
- readonly ageMs: number | null;
7
- constructor(lockPath: string, holder: string, ageMs: number | null);
8
- }
9
- export declare function acquireRegistryLock(registryPath: string): () => void;
10
- export declare function readRegistry(registryPath: string): RegistryData;
11
- export declare function writeRegistry(registryPath: string, data: RegistryData): RegistryData;
12
- export declare function getHome(registryPath: string, tenantId: TenantId): HomeRecord | null;
13
- export declare function putHome(registryPath: string, home: HomeRecord): HomeRecord;
14
- export declare function updateHome(registryPath: string, tenantId: TenantId, mutate: (home: HomeRecord) => HomeRecord | void): HomeRecord;
15
- export declare function listHomes(registryPath: string): HomeRecord[];
@@ -1,180 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, chmodSync } from 'node:fs';
2
- import { dirname, join, basename } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { general } from '../errors.js';
5
- import { nowIso } from '../fs-utils.js';
6
- const LOCK_STALE_MS = 2 * 60_000;
7
- function expandHomePath(path) {
8
- if (path.startsWith('~'))
9
- return join(homedir(), path.slice(1));
10
- return path;
11
- }
12
- function registryLockPath(registryPath) {
13
- return `${registryPath}.lock`;
14
- }
15
- function lockOwnerPath(lockPath) {
16
- return join(lockPath, 'owner.json');
17
- }
18
- function ensureParentDir(path) {
19
- mkdirSync(dirname(path), { recursive: true });
20
- }
21
- function readJson(path) {
22
- return JSON.parse(readFileSync(path, 'utf8'));
23
- }
24
- function normalizeRegistry(value) {
25
- if (value === null || typeof value !== 'object' || Array.isArray(value))
26
- return {};
27
- const out = {};
28
- for (const [tenantId, record] of Object.entries(value)) {
29
- if (record === null || typeof record !== 'object' || Array.isArray(record))
30
- continue;
31
- const obj = record;
32
- if (typeof obj.tenantId !== 'string' || obj.tenantId === '')
33
- continue;
34
- out[tenantId] = obj;
35
- }
36
- return out;
37
- }
38
- function atomicWriteJson(path, value) {
39
- ensureParentDir(path);
40
- const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
41
- const content = JSON.stringify(value, null, 2) + '\n';
42
- writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
43
- try {
44
- renameSync(tmp, path);
45
- chmodSync(path, 0o600);
46
- }
47
- catch (error) {
48
- try {
49
- unlinkSync(tmp);
50
- }
51
- catch {
52
- /* ignore */
53
- }
54
- throw error;
55
- }
56
- }
57
- function lockDiagnostics(lockPath) {
58
- let holder = lockPath;
59
- let ageMs = null;
60
- let stale = false;
61
- try {
62
- const st = statSync(lockPath);
63
- ageMs = Date.now() - st.mtimeMs;
64
- stale = ageMs >= LOCK_STALE_MS;
65
- }
66
- catch {
67
- /* ignore */
68
- }
69
- const ownerFile = lockOwnerPath(lockPath);
70
- try {
71
- const owner = JSON.parse(readFileSync(ownerFile, 'utf8'));
72
- if (typeof owner.holder === 'string' && owner.holder !== '')
73
- holder = owner.holder;
74
- else if (typeof owner.pid === 'number')
75
- holder = `pid ${owner.pid}`;
76
- else if (typeof owner.startedAt === 'string')
77
- holder = owner.startedAt;
78
- }
79
- catch {
80
- /* ignore */
81
- }
82
- return { holder, ageMs, stale };
83
- }
84
- export class RegistryLockError extends Error {
85
- lockPath;
86
- holder;
87
- ageMs;
88
- constructor(lockPath, holder, ageMs) {
89
- super(ageMs === null
90
- ? `registry lock held at ${lockPath} by ${holder}`
91
- : `registry lock held at ${lockPath} by ${holder} (${Math.ceil(ageMs / 1000)}s old)`);
92
- this.lockPath = lockPath;
93
- this.holder = holder;
94
- this.ageMs = ageMs;
95
- this.name = 'RegistryLockError';
96
- }
97
- }
98
- export function acquireRegistryLock(registryPath) {
99
- const lockPath = registryLockPath(registryPath);
100
- ensureParentDir(lockPath);
101
- try {
102
- mkdirSync(lockPath, { mode: 0o700 });
103
- }
104
- catch (error) {
105
- const diagnostics = lockDiagnostics(lockPath);
106
- throw new RegistryLockError(lockPath, diagnostics.holder, diagnostics.ageMs);
107
- }
108
- const owner = {
109
- holder: `${process.pid}@${basename(registryPath)}:${nowIso()}`,
110
- pid: process.pid,
111
- startedAt: nowIso(),
112
- cwd: process.cwd(),
113
- };
114
- atomicWriteJson(lockOwnerPath(lockPath), owner);
115
- return () => {
116
- try {
117
- rmSync(lockPath, { recursive: true, force: true });
118
- }
119
- catch {
120
- /* ignore */
121
- }
122
- };
123
- }
124
- export function readRegistry(registryPath) {
125
- const path = expandHomePath(registryPath);
126
- if (!existsSync(path))
127
- return {};
128
- return normalizeRegistry(readJson(path));
129
- }
130
- export function writeRegistry(registryPath, data) {
131
- const path = expandHomePath(registryPath);
132
- const release = acquireRegistryLock(path);
133
- try {
134
- atomicWriteJson(path, data);
135
- return data;
136
- }
137
- finally {
138
- release();
139
- }
140
- }
141
- export function getHome(registryPath, tenantId) {
142
- const registry = readRegistry(registryPath);
143
- return registry[tenantId] ?? null;
144
- }
145
- export function putHome(registryPath, home) {
146
- const path = expandHomePath(registryPath);
147
- const release = acquireRegistryLock(path);
148
- try {
149
- const registry = readRegistry(path);
150
- registry[home.tenantId] = home;
151
- atomicWriteJson(path, registry);
152
- return home;
153
- }
154
- finally {
155
- release();
156
- }
157
- }
158
- export function updateHome(registryPath, tenantId, mutate) {
159
- const path = expandHomePath(registryPath);
160
- const release = acquireRegistryLock(path);
161
- try {
162
- const registry = readRegistry(path);
163
- const current = registry[tenantId];
164
- if (current === undefined) {
165
- throw general(`no home for tenant ${tenantId}`, { tenantId, registryPath: path });
166
- }
167
- const draft = { ...current };
168
- const mutated = mutate(draft);
169
- const next = mutated === undefined ? draft : mutated;
170
- registry[tenantId] = next;
171
- atomicWriteJson(path, registry);
172
- return next;
173
- }
174
- finally {
175
- release();
176
- }
177
- }
178
- export function listHomes(registryPath) {
179
- return Object.values(readRegistry(registryPath)).sort((a, b) => a.tenantId.localeCompare(b.tenantId));
180
- }
@@ -1,289 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { createHash } from 'node:crypto';
3
- import { readFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import test from 'node:test';
6
- import { ANTHROPIC_AUTHORIZE_URL, ANTHROPIC_CLIENT_ID, ANTHROPIC_REDIRECT_URI, ANTHROPIC_SCOPES, ANTHROPIC_TOKEN_URL, finishAnthropicOAuthSession, redactAnthropicOAuthMessage, startAnthropicOAuthSession, } from '../anthropic-oauth.js';
7
- const PROVIDER_SOURCE_PATH = join(process.cwd(), 'node_modules/@earendil-works/pi-ai/dist/utils/oauth/anthropic.js');
8
- function makeRandomBytes(values) {
9
- let index = 0;
10
- return (size) => {
11
- const value = values[index++];
12
- assert.ok(value, `unexpected randomBytes call ${index} for size ${size}`);
13
- return value;
14
- };
15
- }
16
- function sha256Base64url(input) {
17
- return createHash('sha256').update(input, 'utf8').digest('base64url');
18
- }
19
- function makeFetch(responseBody, calls) {
20
- return async (input, init) => {
21
- calls.push({ input, init });
22
- return {
23
- ok: true,
24
- status: 200,
25
- statusText: 'OK',
26
- async text() {
27
- return JSON.stringify(responseBody);
28
- },
29
- };
30
- };
31
- }
32
- test('startAnthropicOAuthSession builds a PKCE auth URL with Claude Code state bound to the verifier', () => {
33
- const sessionIdBytes = Buffer.alloc(16, 1);
34
- const verifierBytes = Buffer.alloc(32, 3);
35
- const verifier = verifierBytes.toString('base64url');
36
- const shaInputs = [];
37
- const started = startAnthropicOAuthSession('tenant-a', () => 1_000_000, {
38
- randomBytes: makeRandomBytes([sessionIdBytes, verifierBytes]),
39
- sha256: (input) => {
40
- shaInputs.push(input);
41
- return Buffer.alloc(32, 9);
42
- },
43
- });
44
- assert.equal(started.ok, true);
45
- if (!started.ok)
46
- throw new Error('expected success');
47
- assert.ok(started.authUrl.includes('response_type=code'));
48
- assert.ok(started.authUrl.includes('code_challenge_method=S256'));
49
- // Regression: claude.ai rejects the authorize request with "Invalid request
50
- // format" without code=true (the Claude Code authorize flow param).
51
- assert.ok(started.authUrl.includes('code=true'));
52
- const authUrl = new URL(started.authUrl);
53
- assert.equal(authUrl.origin + authUrl.pathname, ANTHROPIC_AUTHORIZE_URL);
54
- assert.equal(authUrl.searchParams.get('client_id'), ANTHROPIC_CLIENT_ID);
55
- assert.equal(authUrl.searchParams.get('redirect_uri'), ANTHROPIC_REDIRECT_URI);
56
- assert.equal(authUrl.searchParams.get('scope'), ANTHROPIC_SCOPES);
57
- assert.equal(authUrl.searchParams.get('response_type'), 'code');
58
- assert.equal(authUrl.searchParams.get('code_challenge_method'), 'S256');
59
- assert.equal(authUrl.searchParams.get('state'), verifier);
60
- assert.equal(authUrl.searchParams.get('code_challenge'), Buffer.alloc(32, 9).toString('base64url'));
61
- assert.ok(!started.authUrl.includes(sessionIdBytes.toString('base64url')));
62
- assert.equal(shaInputs[0], verifier);
63
- });
64
- test('startAnthropicOAuthSession keeps Claude Code state coupled to the PKCE verifier', () => {
65
- const sessionIdBytes = Buffer.alloc(16, 41);
66
- const verifierBytes = Buffer.alloc(32, 42);
67
- const verifier = verifierBytes.toString('base64url');
68
- const started = startAnthropicOAuthSession('tenant-a-pkce', () => 2_000_000, {
69
- randomBytes: makeRandomBytes([sessionIdBytes, verifierBytes]),
70
- });
71
- assert.equal(started.ok, true);
72
- if (!started.ok)
73
- throw new Error('expected success');
74
- const authUrl = new URL(started.authUrl);
75
- const state = authUrl.searchParams.get('state');
76
- assert.ok(state);
77
- assert.equal(state, verifier);
78
- assert.equal(authUrl.searchParams.get('code_challenge'), sha256Base64url(state));
79
- });
80
- test('finishAnthropicOAuthSession rejects foreign state, unknown session, and expired sessions without token exchange', async () => {
81
- const fetchCalls = [];
82
- const started = startAnthropicOAuthSession('tenant-b', () => 10_000, {
83
- randomBytes: makeRandomBytes([Buffer.alloc(16, 4), Buffer.alloc(32, 6)]),
84
- sha256: () => Buffer.alloc(32, 7),
85
- });
86
- assert.equal(started.ok, true);
87
- if (!started.ok)
88
- throw new Error('expected success');
89
- const foreignState = await finishAnthropicOAuthSession({
90
- tenantId: 'tenant-b',
91
- sessionId: started.sessionId,
92
- redirectUrl: 'http://localhost:53692/callback?code=abc123&state=wrong-state',
93
- }, {
94
- now: () => 10_000,
95
- fetch: makeFetch({ access_token: 'x', refresh_token: 'y', expires_in: 600 }, fetchCalls),
96
- });
97
- assert.equal(foreignState.ok, false);
98
- assert.equal(foreignState.status, 400);
99
- assert.equal(fetchCalls.length, 0);
100
- const unknownSession = await finishAnthropicOAuthSession({
101
- tenantId: 'tenant-c',
102
- sessionId: 'missing-session',
103
- redirectUrl: 'http://localhost:53692/callback?code=abc123&state=wrong-state',
104
- }, {
105
- now: () => 10_000,
106
- fetch: makeFetch({ access_token: 'x', refresh_token: 'y', expires_in: 600 }, fetchCalls),
107
- });
108
- assert.equal(unknownSession.ok, false);
109
- assert.equal(unknownSession.status, 400);
110
- assert.equal(fetchCalls.length, 0);
111
- const expiredStart = startAnthropicOAuthSession('tenant-d', () => 20_000, {
112
- randomBytes: makeRandomBytes([Buffer.alloc(16, 8), Buffer.alloc(32, 10)]),
113
- sha256: () => Buffer.alloc(32, 11),
114
- });
115
- assert.equal(expiredStart.ok, true);
116
- if (!expiredStart.ok)
117
- throw new Error('expected success');
118
- const expired = await finishAnthropicOAuthSession({
119
- tenantId: 'tenant-d',
120
- sessionId: expiredStart.sessionId,
121
- redirectUrl: 'http://localhost:53692/callback?code=abc123&state=whatever',
122
- }, {
123
- now: () => 20_000 + 10 * 60_000 + 1,
124
- fetch: makeFetch({ access_token: 'x', refresh_token: 'y', expires_in: 600 }, fetchCalls),
125
- });
126
- assert.equal(expired.ok, false);
127
- assert.equal(expired.status, 400);
128
- assert.equal(fetchCalls.length, 0);
129
- });
130
- // Regression: a stale/zombie in-memory OAuth session used to make /start return
131
- // 409 "already in progress" for the whole 10-min TTL, trapping the user on an
132
- // about:blank popup with no usable authUrl (Hearth live triage 2026-06-29).
133
- // A fresh start must always supersede the prior session and hand back a usable,
134
- // distinct authUrl. Pre-fix this test would have failed on the second start.
135
- test('startAnthropicOAuthSession supersedes a stale pending session and returns a fresh usable authUrl', () => {
136
- const now = 30_000;
137
- const first = startAnthropicOAuthSession('tenant-e', () => now, {
138
- randomBytes: makeRandomBytes([Buffer.alloc(16, 12), Buffer.alloc(32, 14)]),
139
- sha256: () => Buffer.alloc(32, 15),
140
- });
141
- assert.equal(first.ok, true);
142
- if (!first.ok)
143
- throw new Error('expected success');
144
- // Second click while the first session is still live (within TTL): no 409 lock.
145
- const second = startAnthropicOAuthSession('tenant-e', () => now + 1_000, {
146
- randomBytes: makeRandomBytes([Buffer.alloc(16, 16), Buffer.alloc(32, 18)]),
147
- sha256: () => Buffer.alloc(32, 19),
148
- });
149
- assert.equal(second.ok, true);
150
- if (!second.ok)
151
- throw new Error('expected fresh start to succeed, not a 409 lock');
152
- assert.ok(second.authUrl.includes('response_type=code'));
153
- assert.notEqual(second.sessionId, first.sessionId);
154
- // The superseded session's state must no longer be the active one.
155
- assert.notEqual(new URL(second.authUrl).searchParams.get('state'), new URL(first.authUrl).searchParams.get('state'));
156
- });
157
- // Security companion to the supersede fix: after a fresh start replaces the
158
- // pending session, a callback carrying the OLD session's id/state must NOT be
159
- // able to complete auth — newest valid session wins, superseded state fails.
160
- test('finishAnthropicOAuthSession rejects a superseded callback after a fresh start', async () => {
161
- const now = 50_000;
162
- const firstSessionId = Buffer.alloc(16, 30);
163
- const firstVerifierBytes = Buffer.alloc(32, 32);
164
- const firstVerifier = firstVerifierBytes.toString('base64url');
165
- const first = startAnthropicOAuthSession('tenant-g', () => now, {
166
- randomBytes: makeRandomBytes([firstSessionId, firstVerifierBytes]),
167
- sha256: () => Buffer.alloc(32, 33),
168
- });
169
- assert.equal(first.ok, true);
170
- if (!first.ok)
171
- throw new Error('expected success');
172
- // User re-clicks; a new session supersedes the first.
173
- const second = startAnthropicOAuthSession('tenant-g', () => now + 1_000, {
174
- randomBytes: makeRandomBytes([Buffer.alloc(16, 34), Buffer.alloc(32, 36)]),
175
- sha256: () => Buffer.alloc(32, 37),
176
- });
177
- assert.equal(second.ok, true);
178
- if (!second.ok)
179
- throw new Error('expected success');
180
- // A late callback from the FIRST (superseded) session must fail without exchange.
181
- const fetchCalls = [];
182
- const stale = await finishAnthropicOAuthSession({
183
- tenantId: 'tenant-g',
184
- sessionId: first.sessionId,
185
- redirectUrl: `http://localhost:53692/callback?code=stale-code&state=${firstVerifier}`,
186
- }, {
187
- now: () => now + 2_000,
188
- fetch: makeFetch({ access_token: 'x', refresh_token: 'y', expires_in: 600 }, fetchCalls),
189
- });
190
- assert.equal(stale.ok, false);
191
- if (stale.ok)
192
- throw new Error('superseded callback must not complete auth');
193
- assert.equal(stale.status, 400);
194
- assert.equal(fetchCalls.length, 0);
195
- });
196
- test('finishAnthropicOAuthSession exchanges the stored verifier and returns Pi-compatible credentials with the skew applied', async () => {
197
- const sessionIdBytes = Buffer.alloc(16, 24);
198
- const verifierBytes = Buffer.alloc(32, 26);
199
- const verifier = verifierBytes.toString('base64url');
200
- const now = 40_000;
201
- const started = startAnthropicOAuthSession('tenant-f', () => now, {
202
- randomBytes: makeRandomBytes([sessionIdBytes, verifierBytes]),
203
- sha256: (input) => {
204
- assert.equal(input, verifier);
205
- return Buffer.alloc(32, 27);
206
- },
207
- });
208
- assert.equal(started.ok, true);
209
- if (!started.ok)
210
- throw new Error('expected success');
211
- const fetchCalls = [];
212
- const finished = await finishAnthropicOAuthSession({
213
- tenantId: 'tenant-f',
214
- sessionId: started.sessionId,
215
- redirectUrl: `http://localhost:53692/callback?code=auth-code-123&state=${verifier}`,
216
- }, {
217
- now: () => now,
218
- fetch: async (input, init) => {
219
- fetchCalls.push({ input, init });
220
- const request = new Request(String(input), init);
221
- const body = JSON.parse(await request.text());
222
- assert.equal(body.grant_type, 'authorization_code');
223
- assert.equal(body.client_id, ANTHROPIC_CLIENT_ID);
224
- assert.equal(body.code, 'auth-code-123');
225
- assert.equal(body.state, verifier);
226
- assert.equal(body.redirect_uri, ANTHROPIC_REDIRECT_URI);
227
- assert.equal(body.code_verifier, verifier);
228
- return {
229
- ok: true,
230
- status: 200,
231
- statusText: 'OK',
232
- async text() {
233
- return JSON.stringify({
234
- access_token: 'access-token-1',
235
- refresh_token: 'refresh-token-1',
236
- expires_in: 600,
237
- });
238
- },
239
- };
240
- },
241
- });
242
- assert.equal(fetchCalls.length, 1);
243
- assert.equal(finished.ok, true);
244
- if (!finished.ok)
245
- throw new Error('expected success');
246
- assert.deepEqual(finished.credential, {
247
- type: 'oauth',
248
- refresh: 'refresh-token-1',
249
- access: 'access-token-1',
250
- expires: now + 600_000 - 300_000,
251
- });
252
- assert.equal(finished.type, 'oauth');
253
- assert.equal(finished.refresh, 'refresh-token-1');
254
- assert.equal(finished.access, 'access-token-1');
255
- assert.equal(finished.expires, now + 600_000 - 300_000);
256
- });
257
- test('contract guard: the installed Pi Anthropic OAuth source still matches the copied constants', () => {
258
- const source = readFileSync(PROVIDER_SOURCE_PATH, 'utf8');
259
- assert.match(source, /const CLIENT_ID = decode\("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"\);/);
260
- assert.match(source, /const AUTHORIZE_URL = "https:\/\/claude\.ai\/oauth\/authorize";/);
261
- assert.match(source, /const TOKEN_URL = "https:\/\/platform\.claude\.com\/v1\/oauth\/token";/);
262
- assert.match(source, /const CALLBACK_PORT = 53692;/);
263
- assert.match(source, /const CALLBACK_PATH = "\/callback";/);
264
- assert.match(source, /const REDIRECT_URI = `http:\/\/localhost:\$\{CALLBACK_PORT\}\$\{CALLBACK_PATH\}`;/);
265
- assert.match(source, /const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";/);
266
- assert.equal(ANTHROPIC_CLIENT_ID, '9d1c250a-e61b-44d9-88ed-5944d1962f5e');
267
- assert.equal(ANTHROPIC_AUTHORIZE_URL, 'https://claude.ai/oauth/authorize');
268
- assert.equal(ANTHROPIC_TOKEN_URL, 'https://platform.claude.com/v1/oauth/token');
269
- assert.equal(ANTHROPIC_REDIRECT_URI, 'http://localhost:53692/callback');
270
- assert.equal(ANTHROPIC_SCOPES, 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload');
271
- });
272
- test('redaction removes Anthropic OAuth and bearer/token material from log text', () => {
273
- const redacted = redactAnthropicOAuthMessage('Authorization: Bearer owner-token https://localhost:53692/callback?code=auth-code&state=state-value&code_verifier=verifier-value {"access_token":"access-value","refresh_token":"refresh-value","access":"access-two","refresh":"refresh-two","code":"json-code","state":"json-state","code_verifier":"json-verifier"} code=assign-code state=assign-state code_verifier=assign-verifier hearth_owner=owner-cookie');
274
- assert.ok(!redacted.includes('owner-token'));
275
- assert.ok(!redacted.includes('auth-code'));
276
- assert.ok(!redacted.includes('state-value'));
277
- assert.ok(!redacted.includes('verifier-value'));
278
- assert.ok(!redacted.includes('json-code'));
279
- assert.ok(!redacted.includes('json-state'));
280
- assert.ok(!redacted.includes('json-verifier'));
281
- assert.ok(!redacted.includes('assign-code'));
282
- assert.ok(!redacted.includes('assign-state'));
283
- assert.ok(!redacted.includes('assign-verifier'));
284
- assert.ok(!redacted.includes('access-value'));
285
- assert.ok(!redacted.includes('refresh-value'));
286
- assert.ok(!redacted.includes('owner-cookie'));
287
- assert.match(redacted, /\[redacted redirect url\]/);
288
- assert.match(redacted, /Bearer \[redacted\]/);
289
- });
@@ -1,34 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { loadHearthProxyConfig } from '../config.js';
4
- import { proxyInternals } from '../proxy.js';
5
- function slowWork(ms) {
6
- return new Promise((resolve) => {
7
- setTimeout(() => resolve('ok'), ms);
8
- });
9
- }
10
- test('wake proxy defaults the wake timeout to 150s while leaving the readiness probe timeout at 30s', () => {
11
- const config = loadHearthProxyConfig({
12
- HEARTH_OWNER_TOKEN: 'owner-secret-token',
13
- HEARTH_PROXY_HOST: '127.0.0.1',
14
- HEARTH_PROXY_PORT: '8080',
15
- });
16
- assert.equal(config.wakeTimeoutMs, 150_000);
17
- assert.equal(config.readyTimeoutMs, 30_000);
18
- });
19
- test('withWakeTimeout allows a recreate-sized wake to finish before 150s and rejects the old 30s budget', async (t) => {
20
- t.mock.timers.enable({ apis: ['setTimeout'] });
21
- try {
22
- const shortBudget = proxyInternals.withWakeTimeout(slowWork(120_000), 30_000, 'wake proxy request');
23
- await Promise.resolve();
24
- t.mock.timers.tick(30_000);
25
- await assert.rejects(shortBudget, /timed out/);
26
- const longBudget = proxyInternals.withWakeTimeout(slowWork(120_000), 150_000, 'wake proxy request');
27
- await Promise.resolve();
28
- t.mock.timers.tick(120_000);
29
- await assert.doesNotReject(longBudget);
30
- }
31
- finally {
32
- t.mock.timers.reset();
33
- }
34
- });