@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
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { Readable } from 'node:stream';
|
|
7
|
-
import { test } from 'node:test';
|
|
8
|
-
import { AUTH_STORAGE_STATUS_SCRIPT, AUTH_STORAGE_WRITE_SCRIPT, handleModelAuthRequest } from '../model-auth.js';
|
|
9
|
-
import { finishAnthropicOAuthSession, startAnthropicOAuthSession } from '../anthropic-oauth.js';
|
|
10
|
-
const ownerToken = 'owner-secret-token';
|
|
11
|
-
const ownerTokenHash = createHash('sha256').update(ownerToken, 'utf8').digest('hex');
|
|
12
|
-
const publicOrigin = 'https://hearth.example';
|
|
13
|
-
function makeRuntime(tenantId, statePath) {
|
|
14
|
-
return {
|
|
15
|
-
proxy: {
|
|
16
|
-
host: '127.0.0.1',
|
|
17
|
-
port: 7878,
|
|
18
|
-
publicOrigin,
|
|
19
|
-
statePath,
|
|
20
|
-
m0RegistryPath: join(statePath, 'homes.json'),
|
|
21
|
-
ownerUserId: 'owner-user',
|
|
22
|
-
defaultTenantId: tenantId,
|
|
23
|
-
ownerTokenHash,
|
|
24
|
-
idlePauseMs: 60_000,
|
|
25
|
-
wakeTimeoutMs: 30_000,
|
|
26
|
-
readyTimeoutMs: 2_000,
|
|
27
|
-
cookieSecure: true,
|
|
28
|
-
seedHome: null,
|
|
29
|
-
},
|
|
30
|
-
m0: {
|
|
31
|
-
providerName: 'none',
|
|
32
|
-
registryPath: join(statePath, 'homes.json'),
|
|
33
|
-
guestUser: 'agent',
|
|
34
|
-
guestHome: '/home/agent',
|
|
35
|
-
guestCodePath: '/opt/crouter',
|
|
36
|
-
webPort: 7878,
|
|
37
|
-
nodeKind: 'general',
|
|
38
|
-
piPackages: [],
|
|
39
|
-
piAuthJson: null,
|
|
40
|
-
relayToken: null,
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function writeState(statePath, tenantId) {
|
|
45
|
-
writeFileSync(statePath, `${JSON.stringify({
|
|
46
|
-
version: 1,
|
|
47
|
-
owner: {
|
|
48
|
-
ownerUserId: 'owner-user',
|
|
49
|
-
defaultTenantId: tenantId,
|
|
50
|
-
tokenHash: ownerTokenHash,
|
|
51
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
52
|
-
},
|
|
53
|
-
tenants: {},
|
|
54
|
-
}, null, 2)}\n`, 'utf8');
|
|
55
|
-
}
|
|
56
|
-
function makeRequest(method, url, headers = {}, body = '') {
|
|
57
|
-
const req = new Readable({ read() { } });
|
|
58
|
-
req.method = method;
|
|
59
|
-
req.url = url;
|
|
60
|
-
req.headers = headers;
|
|
61
|
-
process.nextTick(() => {
|
|
62
|
-
if (body !== '')
|
|
63
|
-
req.push(body);
|
|
64
|
-
req.push(null);
|
|
65
|
-
});
|
|
66
|
-
return req;
|
|
67
|
-
}
|
|
68
|
-
function makeResponse() {
|
|
69
|
-
return {
|
|
70
|
-
statusCode: 200,
|
|
71
|
-
headersSent: false,
|
|
72
|
-
headers: {},
|
|
73
|
-
body: '',
|
|
74
|
-
writeHead(statusCode, headers) {
|
|
75
|
-
this.statusCode = statusCode;
|
|
76
|
-
this.headers = { ...this.headers, ...headers };
|
|
77
|
-
this.headersSent = true;
|
|
78
|
-
return this;
|
|
79
|
-
},
|
|
80
|
-
setHeader(name, value) {
|
|
81
|
-
this.headers[name.toLowerCase()] = value;
|
|
82
|
-
},
|
|
83
|
-
end(chunk) {
|
|
84
|
-
if (chunk !== undefined)
|
|
85
|
-
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
86
|
-
this.headersSent = true;
|
|
87
|
-
return this;
|
|
88
|
-
},
|
|
89
|
-
destroy() {
|
|
90
|
-
this.headersSent = true;
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
function parseJsonBody(res) {
|
|
95
|
-
return JSON.parse(res.body);
|
|
96
|
-
}
|
|
97
|
-
function extractState(authUrl) {
|
|
98
|
-
return new URL(authUrl).searchParams.get('state') ?? '';
|
|
99
|
-
}
|
|
100
|
-
test('model-auth routes enforce auth, routing, and method guards', async () => {
|
|
101
|
-
const tenantId = 'tenant-auth-gates';
|
|
102
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-model-auth-'));
|
|
103
|
-
const statePath = join(tempDir, 'state.json');
|
|
104
|
-
writeState(statePath, tenantId);
|
|
105
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
106
|
-
for (const [method, url] of [
|
|
107
|
-
['GET', '/__hearth/model-auth/anthropic/status'],
|
|
108
|
-
['POST', '/__hearth/model-auth/anthropic/start'],
|
|
109
|
-
['POST', '/__hearth/model-auth/anthropic/finish'],
|
|
110
|
-
]) {
|
|
111
|
-
const res = makeResponse();
|
|
112
|
-
const handled = await handleModelAuthRequest(makeRequest(method, url, {}, '{}'), res, runtime, { guestSource: async () => ({ ok: true, stdout: '{}', stderr: '', exitCode: 0 }) });
|
|
113
|
-
assert.equal(handled, true);
|
|
114
|
-
assert.equal(res.statusCode, 401);
|
|
115
|
-
}
|
|
116
|
-
{
|
|
117
|
-
const res = makeResponse();
|
|
118
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', {
|
|
119
|
-
cookie: `hearth_owner=${encodeURIComponent(ownerToken)}`,
|
|
120
|
-
origin: 'https://evil.example',
|
|
121
|
-
}), res, runtime, { startSession: () => ({ ok: true, sessionId: 's', authUrl: 'https://example.test', expiresAt: 1 }) });
|
|
122
|
-
assert.equal(handled, true);
|
|
123
|
-
assert.equal(res.statusCode, 403);
|
|
124
|
-
}
|
|
125
|
-
{
|
|
126
|
-
const res = makeResponse();
|
|
127
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start?token=' + encodeURIComponent(ownerToken)), res, runtime, { startSession: () => ({ ok: true, sessionId: 's', authUrl: 'https://example.test', expiresAt: 1 }) });
|
|
128
|
-
assert.equal(handled, true);
|
|
129
|
-
assert.equal(res.statusCode, 401);
|
|
130
|
-
}
|
|
131
|
-
{
|
|
132
|
-
let called = 0;
|
|
133
|
-
const res = makeResponse();
|
|
134
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', {
|
|
135
|
-
authorization: `Bearer ${ownerToken}`,
|
|
136
|
-
}), res, runtime, {
|
|
137
|
-
startSession: (tenant) => {
|
|
138
|
-
called += 1;
|
|
139
|
-
assert.equal(tenant, tenantId);
|
|
140
|
-
return { ok: true, sessionId: 'sess-123', authUrl: 'https://example.test', expiresAt: 2 };
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
assert.equal(handled, true);
|
|
144
|
-
assert.equal(res.statusCode, 200);
|
|
145
|
-
assert.equal(called, 1);
|
|
146
|
-
assert.deepEqual(parseJsonBody(res), {
|
|
147
|
-
provider: 'anthropic',
|
|
148
|
-
sessionId: 'sess-123',
|
|
149
|
-
authUrl: 'https://example.test',
|
|
150
|
-
expiresAt: 2,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
{
|
|
154
|
-
const wrongMethodRes = makeResponse();
|
|
155
|
-
const wrongMethodHandled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/status', { authorization: `Bearer ${ownerToken}` }), wrongMethodRes, runtime);
|
|
156
|
-
assert.equal(wrongMethodHandled, true);
|
|
157
|
-
assert.equal(wrongMethodRes.statusCode, 405);
|
|
158
|
-
const unknownRes = makeResponse();
|
|
159
|
-
const unknownHandled = await handleModelAuthRequest(makeRequest('GET', '/__hearth/model-auth/anthropic/unknown', { authorization: `Bearer ${ownerToken}` }), unknownRes, runtime);
|
|
160
|
-
assert.equal(unknownHandled, true);
|
|
161
|
-
assert.equal(unknownRes.statusCode, 404);
|
|
162
|
-
const falseRes = makeResponse();
|
|
163
|
-
const falseHandled = await handleModelAuthRequest(makeRequest('GET', '/__hearth/not-model-auth', { authorization: `Bearer ${ownerToken}` }), falseRes, runtime);
|
|
164
|
-
assert.equal(falseHandled, false);
|
|
165
|
-
assert.equal(falseRes.headersSent, false);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
test('model-auth start uses the bounded JSON reader and rejects oversized bodies', async () => {
|
|
169
|
-
const tenantId = 'tenant-start-body';
|
|
170
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-model-auth-start-body-'));
|
|
171
|
-
const statePath = join(tempDir, 'state.json');
|
|
172
|
-
writeState(statePath, tenantId);
|
|
173
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
174
|
-
let called = 0;
|
|
175
|
-
{
|
|
176
|
-
const res = makeResponse();
|
|
177
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', { authorization: `Bearer ${ownerToken}` }, ''), res, runtime, {
|
|
178
|
-
startSession: (tenant) => {
|
|
179
|
-
called += 1;
|
|
180
|
-
assert.equal(tenant, tenantId);
|
|
181
|
-
return { ok: true, sessionId: 'sess-empty', authUrl: 'https://example.test/empty', expiresAt: 1 };
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
assert.equal(handled, true);
|
|
185
|
-
assert.equal(res.statusCode, 200);
|
|
186
|
-
}
|
|
187
|
-
{
|
|
188
|
-
const res = makeResponse();
|
|
189
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', { authorization: `Bearer ${ownerToken}` }, JSON.stringify({ note: 'allowed' })), res, runtime, {
|
|
190
|
-
startSession: (tenant) => {
|
|
191
|
-
called += 1;
|
|
192
|
-
assert.equal(tenant, tenantId);
|
|
193
|
-
return { ok: true, sessionId: 'sess-json', authUrl: 'https://example.test/json', expiresAt: 2 };
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
assert.equal(handled, true);
|
|
197
|
-
assert.equal(res.statusCode, 200);
|
|
198
|
-
}
|
|
199
|
-
{
|
|
200
|
-
const res = makeResponse();
|
|
201
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', { authorization: `Bearer ${ownerToken}` }, `{}${' '.repeat(17 * 1024)}`), res, runtime, { startSession: () => { throw new Error('should not be called for oversized bodies'); } });
|
|
202
|
-
assert.equal(handled, true);
|
|
203
|
-
assert.equal(res.statusCode, 400);
|
|
204
|
-
}
|
|
205
|
-
assert.equal(called, 2);
|
|
206
|
-
});
|
|
207
|
-
test('model-auth status uses the fixed guest script and returns only redacted status data', async () => {
|
|
208
|
-
const tenantId = 'tenant-status';
|
|
209
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-model-auth-status-'));
|
|
210
|
-
const statePath = join(tempDir, 'state.json');
|
|
211
|
-
writeState(statePath, tenantId);
|
|
212
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
213
|
-
const calls = [];
|
|
214
|
-
const res = makeResponse();
|
|
215
|
-
const handled = await handleModelAuthRequest(makeRequest('GET', '/__hearth/model-auth/anthropic/status', {
|
|
216
|
-
authorization: `Bearer ${ownerToken}`,
|
|
217
|
-
}), res, runtime, {
|
|
218
|
-
guestSource: async (tenantId, request) => {
|
|
219
|
-
calls.push({ tenantId, request });
|
|
220
|
-
return {
|
|
221
|
-
ok: true,
|
|
222
|
-
exitCode: 0,
|
|
223
|
-
stdout: JSON.stringify({
|
|
224
|
-
storedOauth: true,
|
|
225
|
-
expiresAt: 12345,
|
|
226
|
-
adapterConfigured: true,
|
|
227
|
-
access_token: 'raw-access-token',
|
|
228
|
-
refresh_token: 'raw-refresh-token',
|
|
229
|
-
auth_json: '/home/agent/.pi/agent/auth.json',
|
|
230
|
-
}),
|
|
231
|
-
stderr: '',
|
|
232
|
-
};
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
assert.equal(handled, true);
|
|
236
|
-
assert.equal(res.statusCode, 200);
|
|
237
|
-
assert.deepEqual(parseJsonBody(res), {
|
|
238
|
-
storedOauth: true,
|
|
239
|
-
expiresAt: 12345,
|
|
240
|
-
adapterConfigured: true,
|
|
241
|
-
});
|
|
242
|
-
assert.deepEqual(calls, [{
|
|
243
|
-
tenantId,
|
|
244
|
-
request: {
|
|
245
|
-
kind: 'exec',
|
|
246
|
-
bin: 'node',
|
|
247
|
-
args: ['--input-type=module', '-e', AUTH_STORAGE_STATUS_SCRIPT],
|
|
248
|
-
cwd: '/opt/crouter',
|
|
249
|
-
},
|
|
250
|
-
}]);
|
|
251
|
-
});
|
|
252
|
-
test('model-auth finish uses the fixed guest write script, consumes the session, and redacts tokens', async () => {
|
|
253
|
-
const tenantId = 'tenant-finish';
|
|
254
|
-
const now = 1_700_000_000_000;
|
|
255
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-model-auth-finish-'));
|
|
256
|
-
const statePath = join(tempDir, 'state.json');
|
|
257
|
-
writeState(statePath, tenantId);
|
|
258
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
259
|
-
const calls = [];
|
|
260
|
-
const startRes = makeResponse();
|
|
261
|
-
const startHandled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', {
|
|
262
|
-
authorization: `Bearer ${ownerToken}`,
|
|
263
|
-
}), startRes, runtime, {
|
|
264
|
-
now: () => now,
|
|
265
|
-
startSession: (tenant) => startAnthropicOAuthSession(tenant, () => now),
|
|
266
|
-
});
|
|
267
|
-
assert.equal(startHandled, true);
|
|
268
|
-
assert.equal(startRes.statusCode, 200);
|
|
269
|
-
const startBody = parseJsonBody(startRes);
|
|
270
|
-
const state = extractState(startBody.authUrl);
|
|
271
|
-
assert.notEqual(state, '');
|
|
272
|
-
const finishSession = async (input) => {
|
|
273
|
-
return await finishAnthropicOAuthSession(input, {
|
|
274
|
-
now: () => now,
|
|
275
|
-
fetch: async () => ({
|
|
276
|
-
ok: true,
|
|
277
|
-
status: 200,
|
|
278
|
-
statusText: 'OK',
|
|
279
|
-
text: async () => JSON.stringify({
|
|
280
|
-
access_token: 'live-access-token',
|
|
281
|
-
refresh_token: 'live-refresh-token',
|
|
282
|
-
expires_in: 3600,
|
|
283
|
-
}),
|
|
284
|
-
}),
|
|
285
|
-
});
|
|
286
|
-
};
|
|
287
|
-
const finishRes = makeResponse();
|
|
288
|
-
const finishHandled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', {
|
|
289
|
-
authorization: `Bearer ${ownerToken}`,
|
|
290
|
-
}, JSON.stringify({
|
|
291
|
-
sessionId: startBody.sessionId,
|
|
292
|
-
redirectUrl: `http://localhost:53692/callback?code=code-123&state=${state}`,
|
|
293
|
-
})), finishRes, runtime, {
|
|
294
|
-
now: () => now,
|
|
295
|
-
finishSession,
|
|
296
|
-
guestSource: async (tenantId, request) => {
|
|
297
|
-
calls.push({ tenantId, request });
|
|
298
|
-
return {
|
|
299
|
-
ok: true,
|
|
300
|
-
exitCode: 0,
|
|
301
|
-
stdout: JSON.stringify({
|
|
302
|
-
ok: true,
|
|
303
|
-
provider: 'anthropic',
|
|
304
|
-
storedOauth: true,
|
|
305
|
-
expiresAt: now + 3_300_000,
|
|
306
|
-
access_token: 'write-access-token',
|
|
307
|
-
refresh_token: 'write-refresh-token',
|
|
308
|
-
}),
|
|
309
|
-
stderr: '',
|
|
310
|
-
};
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
assert.equal(finishHandled, true);
|
|
314
|
-
assert.equal(finishRes.statusCode, 200);
|
|
315
|
-
assert.deepEqual(parseJsonBody(finishRes), {
|
|
316
|
-
ok: true,
|
|
317
|
-
provider: 'anthropic',
|
|
318
|
-
sessionId: startBody.sessionId,
|
|
319
|
-
storedOauth: true,
|
|
320
|
-
expiresAt: now + 3_300_000,
|
|
321
|
-
});
|
|
322
|
-
assert.equal(JSON.stringify(parseJsonBody(finishRes)).includes('access'), false);
|
|
323
|
-
assert.equal(JSON.stringify(parseJsonBody(finishRes)).includes('refresh'), false);
|
|
324
|
-
assert.deepEqual(calls, [{
|
|
325
|
-
tenantId,
|
|
326
|
-
request: {
|
|
327
|
-
kind: 'exec',
|
|
328
|
-
bin: 'node',
|
|
329
|
-
args: ['--input-type=module', '-e', AUTH_STORAGE_WRITE_SCRIPT],
|
|
330
|
-
stdin: JSON.stringify({
|
|
331
|
-
provider: 'anthropic',
|
|
332
|
-
credential: {
|
|
333
|
-
type: 'oauth',
|
|
334
|
-
access: 'live-access-token',
|
|
335
|
-
refresh: 'live-refresh-token',
|
|
336
|
-
expires: now + 3_300_000,
|
|
337
|
-
},
|
|
338
|
-
}),
|
|
339
|
-
cwd: '/opt/crouter',
|
|
340
|
-
},
|
|
341
|
-
}]);
|
|
342
|
-
const secondRes = makeResponse();
|
|
343
|
-
const secondHandled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', {
|
|
344
|
-
authorization: `Bearer ${ownerToken}`,
|
|
345
|
-
}, JSON.stringify({
|
|
346
|
-
sessionId: startBody.sessionId,
|
|
347
|
-
redirectUrl: `http://localhost:53692/callback?code=code-123&state=${state}`,
|
|
348
|
-
})), secondRes, runtime, {
|
|
349
|
-
now: () => now,
|
|
350
|
-
finishSession,
|
|
351
|
-
guestSource: async () => ({ ok: true, exitCode: 0, stdout: '{}', stderr: '' }),
|
|
352
|
-
});
|
|
353
|
-
assert.equal(secondHandled, true);
|
|
354
|
-
assert.equal(secondRes.statusCode, 400);
|
|
355
|
-
});
|
|
356
|
-
test('model-auth finish rejects guest failures and strict body validation', async () => {
|
|
357
|
-
const now = 1_700_000_000_000;
|
|
358
|
-
async function makeContext(tenantId) {
|
|
359
|
-
const tempDir = mkdtempSync(join(tmpdir(), `crouter-model-auth-${tenantId}-`));
|
|
360
|
-
const statePath = join(tempDir, 'state.json');
|
|
361
|
-
writeState(statePath, tenantId);
|
|
362
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
363
|
-
async function startSession() {
|
|
364
|
-
const startRes = makeResponse();
|
|
365
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/start', { authorization: `Bearer ${ownerToken}` }), startRes, runtime, { now: () => now, startSession: (tenant) => startAnthropicOAuthSession(tenant, () => now) });
|
|
366
|
-
assert.equal(handled, true);
|
|
367
|
-
assert.equal(startRes.statusCode, 200);
|
|
368
|
-
return parseJsonBody(startRes);
|
|
369
|
-
}
|
|
370
|
-
const finishSession = async (input) => {
|
|
371
|
-
return await finishAnthropicOAuthSession(input, {
|
|
372
|
-
now: () => now,
|
|
373
|
-
fetch: async () => ({
|
|
374
|
-
ok: true,
|
|
375
|
-
status: 200,
|
|
376
|
-
statusText: 'OK',
|
|
377
|
-
text: async () => JSON.stringify({ access_token: 'a', refresh_token: 'b', expires_in: 3600 }),
|
|
378
|
-
}),
|
|
379
|
-
});
|
|
380
|
-
};
|
|
381
|
-
return { runtime, startSession, finishSession };
|
|
382
|
-
}
|
|
383
|
-
{
|
|
384
|
-
const { runtime, startSession, finishSession } = await makeContext('tenant-finish-bridge');
|
|
385
|
-
const { sessionId, authUrl } = await startSession();
|
|
386
|
-
const state = extractState(authUrl);
|
|
387
|
-
const callbackUrl = `http://localhost:53692/callback?code=code-456&state=${state}`;
|
|
388
|
-
const res = makeResponse();
|
|
389
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', { authorization: `Bearer ${ownerToken}` }, JSON.stringify({ sessionId, redirectUrl: callbackUrl })), res, runtime, {
|
|
390
|
-
now: () => now,
|
|
391
|
-
finishSession,
|
|
392
|
-
guestSource: async () => ({ ok: true, exitCode: 1, stdout: '', stderr: 'boom' }),
|
|
393
|
-
});
|
|
394
|
-
assert.equal(handled, true);
|
|
395
|
-
assert.equal(res.statusCode, 502);
|
|
396
|
-
}
|
|
397
|
-
{
|
|
398
|
-
const { runtime, startSession, finishSession } = await makeContext('tenant-finish-status');
|
|
399
|
-
const { sessionId, authUrl } = await startSession();
|
|
400
|
-
const state = extractState(authUrl);
|
|
401
|
-
const callbackUrl = `http://localhost:53692/callback?code=code-456&state=${state}`;
|
|
402
|
-
const res = makeResponse();
|
|
403
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', { authorization: `Bearer ${ownerToken}` }, JSON.stringify({ sessionId, redirectUrl: callbackUrl })), res, runtime, {
|
|
404
|
-
now: () => now,
|
|
405
|
-
finishSession,
|
|
406
|
-
guestSource: async () => ({
|
|
407
|
-
ok: true,
|
|
408
|
-
exitCode: 0,
|
|
409
|
-
stdout: JSON.stringify({ ok: true, provider: 'anthropic', storedOauth: false, expiresAt: now }),
|
|
410
|
-
stderr: '',
|
|
411
|
-
}),
|
|
412
|
-
});
|
|
413
|
-
assert.equal(handled, true);
|
|
414
|
-
assert.equal(res.statusCode, 502);
|
|
415
|
-
}
|
|
416
|
-
{
|
|
417
|
-
const { runtime, startSession, finishSession } = await makeContext('tenant-finish-body-limit');
|
|
418
|
-
const { sessionId, authUrl } = await startSession();
|
|
419
|
-
const state = extractState(authUrl);
|
|
420
|
-
const callbackUrl = `http://localhost:53692/callback?code=code-456&state=${state}`;
|
|
421
|
-
const overLimitBody = `${JSON.stringify({ sessionId, redirectUrl: callbackUrl })}${' '.repeat(17 * 1024)}`;
|
|
422
|
-
const res = makeResponse();
|
|
423
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', { authorization: `Bearer ${ownerToken}` }, overLimitBody), res, runtime, { now: () => now, finishSession, guestSource: async () => ({ ok: true, exitCode: 0, stdout: '{}', stderr: '' }) });
|
|
424
|
-
assert.equal(handled, true);
|
|
425
|
-
assert.equal(res.statusCode, 400);
|
|
426
|
-
}
|
|
427
|
-
{
|
|
428
|
-
const { runtime, startSession, finishSession } = await makeContext('tenant-finish-body-extra');
|
|
429
|
-
const { sessionId, authUrl } = await startSession();
|
|
430
|
-
const state = extractState(authUrl);
|
|
431
|
-
const callbackUrl = `http://localhost:53692/callback?code=code-456&state=${state}`;
|
|
432
|
-
const res = makeResponse();
|
|
433
|
-
const handled = await handleModelAuthRequest(makeRequest('POST', '/__hearth/model-auth/anthropic/finish', { authorization: `Bearer ${ownerToken}` }, JSON.stringify({ sessionId, redirectUrl: callbackUrl, extra: true })), res, runtime, { now: () => now, finishSession, guestSource: async () => ({ ok: true, exitCode: 0, stdout: '{}', stderr: '' }) });
|
|
434
|
-
assert.equal(handled, true);
|
|
435
|
-
assert.equal(res.statusCode, 400);
|
|
436
|
-
}
|
|
437
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { isPublicStaticAssetPath } from '../proxy.js';
|
|
4
|
-
test('wake proxy treats SPA bundle and shell assets as public', () => {
|
|
5
|
-
assert.equal(isPublicStaticAssetPath('/assets/index-1234abcd.js'), true);
|
|
6
|
-
assert.equal(isPublicStaticAssetPath('/assets/style-1234abcd.css'), true);
|
|
7
|
-
assert.equal(isPublicStaticAssetPath('/favicon.ico'), true);
|
|
8
|
-
assert.equal(isPublicStaticAssetPath('/vite.svg'), true);
|
|
9
|
-
});
|
|
10
|
-
test('wake proxy keeps app routes behind auth', () => {
|
|
11
|
-
assert.equal(isPublicStaticAssetPath('/'), false);
|
|
12
|
-
assert.equal(isPublicStaticAssetPath('/views/chat'), false);
|
|
13
|
-
assert.equal(isPublicStaticAssetPath('/__crtr/events'), false);
|
|
14
|
-
assert.equal(isPublicStaticAssetPath('/node/mqt64sus-e71caa94'), false);
|
|
15
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { test } from 'node:test';
|
|
6
|
-
import { ensureAwake, configureWakeProxyRuntime, wakeProxyInternals } from '../home.js';
|
|
7
|
-
// Regression for the live soak stall (REPORT.md): every HTTP forward and WS connect funneled through
|
|
8
|
-
// the per-tenant serial wake lock, so a reconnect/SSE/asset burst queued behind each other for up to
|
|
9
|
-
// the 150s wake timeout while the home was directly healthy (<0.4s). The warm read path must NOT run
|
|
10
|
-
// N serial slow probes and must NOT wait behind a stale slow wake when a fresh warm verdict exists.
|
|
11
|
-
const tenantId = 'silas';
|
|
12
|
-
const machineId = 'igd2fkrn1y1ol3vw5i6mt';
|
|
13
|
-
const nodeId = 'mqt64sus-e71caa94';
|
|
14
|
-
const routeUrl = 'https://7878-igd2fkrn1y1ol3vw5i6mt.example.dev';
|
|
15
|
-
const now = '2026-06-25T00:00:00.000Z';
|
|
16
|
-
function makeHome(overrides = {}) {
|
|
17
|
-
return {
|
|
18
|
-
tenantId,
|
|
19
|
-
providerName: 'none',
|
|
20
|
-
machineId,
|
|
21
|
-
homeStorage: {
|
|
22
|
-
kind: 'volume',
|
|
23
|
-
durability: 'durable-volume',
|
|
24
|
-
longTermContract: true,
|
|
25
|
-
ownerMachineId: machineId,
|
|
26
|
-
guestHome: '/home/agent',
|
|
27
|
-
volumeId: '48b59d07-8cc9-46f9-bdfd-ac99a55dff40',
|
|
28
|
-
mountPath: '/home/agent',
|
|
29
|
-
},
|
|
30
|
-
state: 'running',
|
|
31
|
-
nodeId,
|
|
32
|
-
relayToken: 'relay-token',
|
|
33
|
-
webRoute: {
|
|
34
|
-
routeId: '7878-igd2fkrn1y1ol3vw5i6mt.example.dev',
|
|
35
|
-
machineId,
|
|
36
|
-
targetPort: 7878,
|
|
37
|
-
url: routeUrl,
|
|
38
|
-
concreteUrl: routeUrl,
|
|
39
|
-
concreteUrlSource: 'provider',
|
|
40
|
-
createdAt: now,
|
|
41
|
-
raw: null,
|
|
42
|
-
},
|
|
43
|
-
createdAt: now,
|
|
44
|
-
updatedAt: now,
|
|
45
|
-
...overrides,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
function makeRuntime(registryPath, statePath) {
|
|
49
|
-
return {
|
|
50
|
-
proxy: {
|
|
51
|
-
host: '127.0.0.1',
|
|
52
|
-
port: 7878,
|
|
53
|
-
publicOrigin: 'https://hearth-wake-proxy.fly.dev',
|
|
54
|
-
statePath,
|
|
55
|
-
m0RegistryPath: registryPath,
|
|
56
|
-
ownerUserId: 'owner-user',
|
|
57
|
-
defaultTenantId: tenantId,
|
|
58
|
-
ownerTokenHash: 'hash',
|
|
59
|
-
idlePauseMs: 0,
|
|
60
|
-
wakeTimeoutMs: 30_000,
|
|
61
|
-
readyTimeoutMs: 2_000,
|
|
62
|
-
cookieSecure: true,
|
|
63
|
-
seedHome: null,
|
|
64
|
-
},
|
|
65
|
-
m0: {
|
|
66
|
-
providerName: 'none',
|
|
67
|
-
registryPath,
|
|
68
|
-
guestUser: 'agent',
|
|
69
|
-
guestHome: '/home/agent',
|
|
70
|
-
guestCodePath: '/opt/crouter',
|
|
71
|
-
webPort: 7878,
|
|
72
|
-
nodeKind: 'general',
|
|
73
|
-
piPackages: [],
|
|
74
|
-
piAuthJson: null,
|
|
75
|
-
relayToken: null,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
function setup() {
|
|
80
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-warm-'));
|
|
81
|
-
const registryPath = join(tempDir, 'homes.json');
|
|
82
|
-
const statePath = join(tempDir, 'state.json');
|
|
83
|
-
writeFileSync(registryPath, `${JSON.stringify({ [tenantId]: makeHome() }, null, 2)}\n`, 'utf8');
|
|
84
|
-
// configureWakeProxyRuntime resets the warm-verdict/in-flight caches, isolating each test.
|
|
85
|
-
configureWakeProxyRuntime(makeRuntime(registryPath, statePath));
|
|
86
|
-
}
|
|
87
|
-
const awakened = { tenantId, nodeId, routeUrl, relayToken: 'relay-token', machineId, webPort: 7878 };
|
|
88
|
-
test('concurrent ensureAwake calls coalesce onto ONE slow probe instead of N serial probes', async (t) => {
|
|
89
|
-
setup();
|
|
90
|
-
let releaseProbe;
|
|
91
|
-
const probeGate = new Promise((resolve) => {
|
|
92
|
-
releaseProbe = resolve;
|
|
93
|
-
});
|
|
94
|
-
const probe = t.mock.method(wakeProxyInternals, 'probeRelayServing', async () => {
|
|
95
|
-
await probeGate;
|
|
96
|
-
return true;
|
|
97
|
-
});
|
|
98
|
-
t.mock.method(wakeProxyInternals, 'touchTenantWake', async () => undefined);
|
|
99
|
-
t.mock.method(wakeProxyInternals, 'captureAwakeHome', async () => awakened);
|
|
100
|
-
const wake = t.mock.method(wakeProxyInternals, 'wakeHome', async () => {
|
|
101
|
-
throw new Error('wakeHome should not run on the warm path');
|
|
102
|
-
});
|
|
103
|
-
// Fire five concurrent callers while the (slow) probe is still gated. They must all share the
|
|
104
|
-
// single in-flight wake — only one probe runs, not five serialized behind the lock.
|
|
105
|
-
const calls = [
|
|
106
|
-
ensureAwake(tenantId),
|
|
107
|
-
ensureAwake(tenantId),
|
|
108
|
-
ensureAwake(tenantId),
|
|
109
|
-
ensureAwake(tenantId),
|
|
110
|
-
ensureAwake(tenantId),
|
|
111
|
-
];
|
|
112
|
-
// The lock dispatches runEnsureAwake on a microtask, so let that settle before counting; the
|
|
113
|
-
// probe is gated so it stays at exactly one in-flight call.
|
|
114
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
115
|
-
const probeCallsWhileGated = probe.mock.callCount();
|
|
116
|
-
releaseProbe();
|
|
117
|
-
const results = await Promise.all(calls);
|
|
118
|
-
assert.equal(probeCallsWhileGated, 1);
|
|
119
|
-
assert.equal(probe.mock.callCount(), 1);
|
|
120
|
-
assert.equal(wake.mock.callCount(), 0);
|
|
121
|
-
for (const result of results)
|
|
122
|
-
assert.equal(result.routeUrl, routeUrl);
|
|
123
|
-
});
|
|
124
|
-
test('a fresh warm verdict serves concurrent reads WITHOUT re-probing or waiting on the slow path', async (t) => {
|
|
125
|
-
setup();
|
|
126
|
-
// Freeze time so the verdict stays inside its TTL across the test.
|
|
127
|
-
t.mock.method(wakeProxyInternals, 'now', () => 1_000);
|
|
128
|
-
const probe = t.mock.method(wakeProxyInternals, 'probeRelayServing', async () => true);
|
|
129
|
-
t.mock.method(wakeProxyInternals, 'touchTenantWake', async () => undefined);
|
|
130
|
-
t.mock.method(wakeProxyInternals, 'captureAwakeHome', async () => awakened);
|
|
131
|
-
// Prime: first call probes once and records the warm verdict.
|
|
132
|
-
await ensureAwake(tenantId);
|
|
133
|
-
assert.equal(probe.mock.callCount(), 1);
|
|
134
|
-
// Concurrent reads inside the TTL must resolve purely from the warm verdict — no extra probe,
|
|
135
|
-
// no re-entering the slow wake path. A regression that dropped back into the lock would bump the
|
|
136
|
-
// probe count past 1 here.
|
|
137
|
-
const results = await Promise.all([ensureAwake(tenantId), ensureAwake(tenantId), ensureAwake(tenantId)]);
|
|
138
|
-
assert.equal(probe.mock.callCount(), 1);
|
|
139
|
-
for (const result of results)
|
|
140
|
-
assert.equal(result.routeUrl, routeUrl);
|
|
141
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|