@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.
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +10 -5
- package/dist/clients/attach/attach-cmd.js +732 -704
- package/dist/core/__tests__/fixtures/fake-engine.js +18 -8
- package/dist/core/__tests__/flagship-lifecycle.test.js +4 -1
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +1 -1
- package/dist/core/__tests__/full/detach-focus.test.js +15 -1
- package/dist/core/__tests__/live-mutation-verbs.test.js +8 -2
- package/dist/core/__tests__/live-mutation.test.js +7 -2
- package/dist/core/hearth/config.js +3 -6
- package/dist/core/hearth/index.d.ts +0 -1
- package/dist/core/hearth/index.js +0 -1
- package/dist/core/hearth/model-auth-guest.js +9 -14
- package/dist/core/hearth/providers/blaxel.js +3 -3
- package/dist/core/hearth/types.d.ts +0 -1
- package/dist/web-client/assets/index-DUThOUzU.css +2 -0
- package/dist/web-client/assets/index-IAJVtuVe.js +80 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +2 -3
- package/dist/core/hearth/registry.d.ts +0 -15
- package/dist/core/hearth/registry.js +0 -180
- package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.js +0 -289
- package/dist/hearth/wake-proxy/__tests__/config-timeout.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/config-timeout.test.js +0 -34
- package/dist/hearth/wake-proxy/__tests__/guest-source.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/guest-source.test.js +0 -203
- package/dist/hearth/wake-proxy/__tests__/hardening.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/hardening.test.js +0 -59
- package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.js +0 -372
- package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.js +0 -258
- package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.js +0 -437
- package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.js +0 -15
- package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.js +0 -141
- package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.js +0 -143
- package/dist/hearth/wake-proxy/anthropic-oauth.d.ts +0 -58
- package/dist/hearth/wake-proxy/anthropic-oauth.js +0 -189
- package/dist/hearth/wake-proxy/auth.d.ts +0 -22
- package/dist/hearth/wake-proxy/auth.js +0 -128
- package/dist/hearth/wake-proxy/config.d.ts +0 -2
- package/dist/hearth/wake-proxy/config.js +0 -151
- package/dist/hearth/wake-proxy/guest-source.d.ts +0 -14
- package/dist/hearth/wake-proxy/guest-source.js +0 -130
- package/dist/hearth/wake-proxy/hearth-status.d.ts +0 -44
- package/dist/hearth/wake-proxy/hearth-status.js +0 -267
- package/dist/hearth/wake-proxy/home.d.ts +0 -61
- package/dist/hearth/wake-proxy/home.js +0 -333
- package/dist/hearth/wake-proxy/main.d.ts +0 -1
- package/dist/hearth/wake-proxy/main.js +0 -134
- package/dist/hearth/wake-proxy/model-auth.d.ts +0 -13
- package/dist/hearth/wake-proxy/model-auth.js +0 -345
- package/dist/hearth/wake-proxy/proxy.d.ts +0 -35
- package/dist/hearth/wake-proxy/proxy.js +0 -716
- package/dist/hearth/wake-proxy/public-source-gate.d.ts +0 -9
- package/dist/hearth/wake-proxy/public-source-gate.js +0 -409
- package/dist/hearth/wake-proxy/redact.d.ts +0 -1
- package/dist/hearth/wake-proxy/redact.js +0 -17
- package/dist/hearth/wake-proxy/server.d.ts +0 -11
- package/dist/hearth/wake-proxy/server.js +0 -142
- package/dist/hearth/wake-proxy/state.d.ts +0 -18
- package/dist/hearth/wake-proxy/state.js +0 -342
- package/dist/hearth/wake-proxy/types.d.ts +0 -76
- package/dist/hearth/wake-proxy/types.js +0 -1
- package/dist/web-client/assets/index-BRKxe-hy.js +0 -80
- 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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": "
|
|
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 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -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 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|