@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,203 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { requireSuccessfulExec, runGuestSource } from '../guest-source.js';
|
|
4
|
-
const tenantId = 'tenant-1';
|
|
5
|
-
const routeUrl = 'https://guest.example';
|
|
6
|
-
const relayToken = 'relay-token';
|
|
7
|
-
function awakenedHome() {
|
|
8
|
-
return {
|
|
9
|
-
tenantId,
|
|
10
|
-
nodeId: 'mqt64sus-e71caa94',
|
|
11
|
-
routeUrl,
|
|
12
|
-
relayToken,
|
|
13
|
-
machineId: 'igd2fkrn1y1ol3vw5i6mt',
|
|
14
|
-
webPort: 7878,
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
function activityHandle() {
|
|
18
|
-
return {
|
|
19
|
-
tenantId,
|
|
20
|
-
kind: 'wake',
|
|
21
|
-
acquiredAt: '2026-06-26T00:00:00.000Z',
|
|
22
|
-
closed: false,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function jsonResponse(raw, ok = true, status = 200, statusText = 'OK') {
|
|
26
|
-
return {
|
|
27
|
-
ok,
|
|
28
|
-
status,
|
|
29
|
-
statusText,
|
|
30
|
-
async text() {
|
|
31
|
-
return JSON.stringify(raw);
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
function makeDeps(overrides = {}) {
|
|
36
|
-
const fetchCalls = [];
|
|
37
|
-
const state = { acquired: 0, released: 0, releaseSnapshot: null };
|
|
38
|
-
const handle = activityHandle();
|
|
39
|
-
const defaultFetch = (async (url, init) => {
|
|
40
|
-
fetchCalls.push({ url: String(url), init: init ?? {} });
|
|
41
|
-
return jsonResponse({ ok: true, exitCode: 0, stdout: 'ok', stderr: '' });
|
|
42
|
-
});
|
|
43
|
-
const deps = {
|
|
44
|
-
ensureAwake: overrides.ensureAwake ?? (async () => awakenedHome()),
|
|
45
|
-
fetch: overrides.fetch ?? defaultFetch,
|
|
46
|
-
acquireActivity: overrides.acquireActivity ?? (() => {
|
|
47
|
-
state.acquired += 1;
|
|
48
|
-
return handle;
|
|
49
|
-
}),
|
|
50
|
-
releaseActivity: overrides.releaseActivity ?? (() => {
|
|
51
|
-
state.released += 1;
|
|
52
|
-
state.releaseSnapshot = { tenantId, counts: { wake: 0, http: 0, ws: 0 }, total: 0, idleDeadlineAt: null };
|
|
53
|
-
return state.releaseSnapshot;
|
|
54
|
-
}),
|
|
55
|
-
now: overrides.now ?? (() => 1_700_000_000_000),
|
|
56
|
-
wakeTimeoutMs: overrides.wakeTimeoutMs ?? 50,
|
|
57
|
-
bridgeTimeoutMs: overrides.bridgeTimeoutMs ?? 50,
|
|
58
|
-
redact: overrides.redact,
|
|
59
|
-
fetchCalls,
|
|
60
|
-
};
|
|
61
|
-
return { deps, state };
|
|
62
|
-
}
|
|
63
|
-
test('runGuestSource acquires and releases activity and forwards the exact server-side request', async () => {
|
|
64
|
-
const { deps, state } = makeDeps();
|
|
65
|
-
const request = {
|
|
66
|
-
kind: 'exec',
|
|
67
|
-
bin: 'crtr',
|
|
68
|
-
args: ['canvas', 'snapshot', '--json'],
|
|
69
|
-
cwd: '/opt/crouter',
|
|
70
|
-
};
|
|
71
|
-
const raw = await runGuestSource(tenantId, request, deps);
|
|
72
|
-
assert.equal(raw.ok, true);
|
|
73
|
-
assert.equal(raw.exitCode, 0);
|
|
74
|
-
assert.equal(raw.stdout, 'ok');
|
|
75
|
-
assert.equal(raw.stderr, '');
|
|
76
|
-
assert.equal(state.acquired, 1);
|
|
77
|
-
assert.equal(state.released, 1);
|
|
78
|
-
assert.equal(deps.fetchCalls.length, 1);
|
|
79
|
-
assert.equal(deps.fetchCalls[0].url, `${routeUrl}/__crtr/source`);
|
|
80
|
-
assert.deepEqual(deps.fetchCalls[0].init?.headers, {
|
|
81
|
-
authorization: `Bearer ${relayToken}`,
|
|
82
|
-
'content-type': 'application/json',
|
|
83
|
-
});
|
|
84
|
-
assert.equal(deps.fetchCalls[0].init?.body, JSON.stringify(request));
|
|
85
|
-
});
|
|
86
|
-
test('runGuestSource releases activity on wake timeout', async () => {
|
|
87
|
-
let resolved = false;
|
|
88
|
-
const { deps, state } = makeDeps({
|
|
89
|
-
ensureAwake: async () => await new Promise((resolve) => {
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
resolved = true;
|
|
92
|
-
resolve(awakenedHome());
|
|
93
|
-
}, 25);
|
|
94
|
-
}),
|
|
95
|
-
wakeTimeoutMs: 1,
|
|
96
|
-
fetch: async () => {
|
|
97
|
-
throw new Error('fetch should not run after wake timeout');
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
const raw = await runGuestSource(tenantId, { kind: 'exec', bin: 'crtr', args: ['surface', 'view', 'list'] }, deps);
|
|
101
|
-
assert.equal(raw.ok, false);
|
|
102
|
-
assert.equal(raw.stdout, '');
|
|
103
|
-
assert.match(raw.stderr, /timed out/i);
|
|
104
|
-
assert.equal(state.acquired, 1);
|
|
105
|
-
assert.equal(state.released, 1);
|
|
106
|
-
assert.equal(resolved, false);
|
|
107
|
-
});
|
|
108
|
-
test('runGuestSource releases activity when the bridge fetch aborts on timeout', async () => {
|
|
109
|
-
const { deps, state } = makeDeps({
|
|
110
|
-
bridgeTimeoutMs: 1,
|
|
111
|
-
fetch: (async (_url, init) => {
|
|
112
|
-
const signal = init?.signal;
|
|
113
|
-
assert.ok(signal instanceof AbortSignal);
|
|
114
|
-
await new Promise((_resolve, reject) => {
|
|
115
|
-
signal.addEventListener('abort', () => {
|
|
116
|
-
reject(new Error('bridge fetch failed with Bearer secret https://localhost:53692/callback?code=abc&state=def access_token=tok refresh_token=ref'));
|
|
117
|
-
}, { once: true });
|
|
118
|
-
});
|
|
119
|
-
}),
|
|
120
|
-
});
|
|
121
|
-
const raw = await runGuestSource(tenantId, { kind: 'exec', bin: 'crtr', args: ['surface', 'view', 'list'] }, deps);
|
|
122
|
-
assert.equal(raw.ok, false);
|
|
123
|
-
assert.equal(raw.stdout, '');
|
|
124
|
-
assert.doesNotMatch(raw.stderr, /Bearer secret/);
|
|
125
|
-
assert.doesNotMatch(raw.stderr, /code=abc/);
|
|
126
|
-
assert.doesNotMatch(raw.stderr, /access_token/);
|
|
127
|
-
assert.doesNotMatch(raw.stderr, /refresh_token/);
|
|
128
|
-
assert.equal(state.acquired, 1);
|
|
129
|
-
assert.equal(state.released, 1);
|
|
130
|
-
});
|
|
131
|
-
test('runGuestSource normalizes HTTP and bridge-shape failures without throwing', async () => {
|
|
132
|
-
const { deps: httpDeps } = makeDeps({
|
|
133
|
-
fetch: (async () => ({
|
|
134
|
-
ok: false,
|
|
135
|
-
status: 500,
|
|
136
|
-
statusText: 'Internal Server Error',
|
|
137
|
-
async text() {
|
|
138
|
-
return 'Authorization: Bearer secret-token code=abc&state=def access_token="tok" refresh_token="ref" https://localhost:53692/callback?code=abc&state=def';
|
|
139
|
-
},
|
|
140
|
-
})),
|
|
141
|
-
});
|
|
142
|
-
const { deps: malformedDeps } = makeDeps({
|
|
143
|
-
fetch: (async () => ({
|
|
144
|
-
ok: true,
|
|
145
|
-
status: 200,
|
|
146
|
-
statusText: 'OK',
|
|
147
|
-
async text() {
|
|
148
|
-
return 'this is not json Authorization: Bearer secret-token code=abc access_token=tok';
|
|
149
|
-
},
|
|
150
|
-
})),
|
|
151
|
-
});
|
|
152
|
-
const { deps: shapeDeps } = makeDeps({
|
|
153
|
-
fetch: (async () => ({
|
|
154
|
-
ok: true,
|
|
155
|
-
status: 200,
|
|
156
|
-
statusText: 'OK',
|
|
157
|
-
async text() {
|
|
158
|
-
return JSON.stringify({ ok: true, stdout: 1, stderr: '' });
|
|
159
|
-
},
|
|
160
|
-
})),
|
|
161
|
-
});
|
|
162
|
-
const httpRaw = await runGuestSource(tenantId, { kind: 'exec', bin: 'crtr', args: ['canvas', 'snapshot', '--json'] }, httpDeps);
|
|
163
|
-
const malformedRaw = await runGuestSource(tenantId, { kind: 'exec', bin: 'crtr', args: ['canvas', 'snapshot', '--json'] }, malformedDeps);
|
|
164
|
-
const shapeRaw = await runGuestSource(tenantId, { kind: 'exec', bin: 'crtr', args: ['canvas', 'snapshot', '--json'] }, shapeDeps);
|
|
165
|
-
assert.equal(httpRaw.ok, false);
|
|
166
|
-
assert.equal(httpRaw.stdout, '');
|
|
167
|
-
assert.match(httpRaw.stderr, /HTTP 500/);
|
|
168
|
-
assert.doesNotMatch(httpRaw.stderr, /Bearer secret-token/);
|
|
169
|
-
assert.doesNotMatch(httpRaw.stderr, /code=abc/);
|
|
170
|
-
assert.doesNotMatch(httpRaw.stderr, /access_token/);
|
|
171
|
-
assert.doesNotMatch(httpRaw.stderr, /refresh_token/);
|
|
172
|
-
assert.doesNotMatch(httpRaw.stderr, /localhost:53692\/callback\?code=abc/);
|
|
173
|
-
assert.equal(malformedRaw.ok, false);
|
|
174
|
-
assert.equal(malformedRaw.stdout, '');
|
|
175
|
-
assert.match(malformedRaw.stderr, /invalid RawResponse JSON/i);
|
|
176
|
-
assert.doesNotMatch(malformedRaw.stderr, /Bearer secret-token/);
|
|
177
|
-
assert.doesNotMatch(malformedRaw.stderr, /code=abc/);
|
|
178
|
-
assert.doesNotMatch(malformedRaw.stderr, /access_token/);
|
|
179
|
-
assert.equal(shapeRaw.ok, false);
|
|
180
|
-
assert.equal(shapeRaw.stdout, '');
|
|
181
|
-
assert.match(shapeRaw.stderr, /invalid RawResponse JSON/i);
|
|
182
|
-
});
|
|
183
|
-
test('requireSuccessfulExec accepts only ok=true with exitCode=0 and redacts stderr-bearing failures', () => {
|
|
184
|
-
const raw = { ok: true, exitCode: 0, stdout: 'ok', stderr: '' };
|
|
185
|
-
assert.equal(requireSuccessfulExec(raw, 'write anthropic auth'), raw);
|
|
186
|
-
assert.throws(() => requireSuccessfulExec({ ok: true, exitCode: 1, stdout: '', stderr: 'boom' }, 'write anthropic auth'));
|
|
187
|
-
assert.throws(() => requireSuccessfulExec({ ok: false, stdout: '', stderr: 'transport failed' }, 'write anthropic auth'));
|
|
188
|
-
assert.throws(() => requireSuccessfulExec({ ok: true, stdout: '', stderr: '' }, 'write anthropic auth'));
|
|
189
|
-
try {
|
|
190
|
-
requireSuccessfulExec({ ok: true, exitCode: 0, stdout: '', stderr: 'Authorization: Bearer secret-token access_token="tok" refresh_token="ref" code_verifier=verifier' }, 'write anthropic auth');
|
|
191
|
-
assert.fail('expected stderr-bearing success to fail');
|
|
192
|
-
}
|
|
193
|
-
catch (error) {
|
|
194
|
-
assert.ok(error instanceof Error);
|
|
195
|
-
const details = error.details;
|
|
196
|
-
assert.ok(details);
|
|
197
|
-
assert.equal(typeof details?.error, 'string');
|
|
198
|
-
assert.doesNotMatch(String(details?.error), /Bearer secret-token/);
|
|
199
|
-
assert.doesNotMatch(String(details?.error), /access_token/);
|
|
200
|
-
assert.doesNotMatch(String(details?.error), /refresh_token/);
|
|
201
|
-
assert.doesNotMatch(String(details?.error), /code_verifier=verifier/);
|
|
202
|
-
}
|
|
203
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import { proxyInternals } from '../proxy.js';
|
|
5
|
-
class FakeWs extends EventEmitter {
|
|
6
|
-
readyState = 1;
|
|
7
|
-
pingCount = 0;
|
|
8
|
-
terminateCount = 0;
|
|
9
|
-
ping() {
|
|
10
|
-
this.pingCount += 1;
|
|
11
|
-
}
|
|
12
|
-
terminate() {
|
|
13
|
-
this.terminateCount += 1;
|
|
14
|
-
this.readyState = 3;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
test('attachWsKeepalive pings on cadence, resets on pong, and stops cleanly', async (t) => {
|
|
18
|
-
t.mock.timers.enable({ apis: ['setInterval'] });
|
|
19
|
-
try {
|
|
20
|
-
const socket = new FakeWs();
|
|
21
|
-
const stop = proxyInternals.attachWsKeepalive(socket, { nodeId: 'node-1', tenantId: 'tenant-1', leg: 'browser' }, { intervalMs: 1 });
|
|
22
|
-
assert.equal(socket.listenerCount('pong'), 1);
|
|
23
|
-
t.mock.timers.tick(1);
|
|
24
|
-
assert.equal(socket.pingCount, 1);
|
|
25
|
-
assert.equal(socket.terminateCount, 0);
|
|
26
|
-
socket.emit('pong');
|
|
27
|
-
t.mock.timers.tick(1);
|
|
28
|
-
assert.equal(socket.pingCount, 2);
|
|
29
|
-
assert.equal(socket.terminateCount, 0);
|
|
30
|
-
stop();
|
|
31
|
-
assert.equal(socket.listenerCount('pong'), 0);
|
|
32
|
-
t.mock.timers.tick(10);
|
|
33
|
-
assert.equal(socket.pingCount, 2);
|
|
34
|
-
assert.equal(socket.terminateCount, 0);
|
|
35
|
-
}
|
|
36
|
-
finally {
|
|
37
|
-
t.mock.timers.reset();
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
test('attachWsKeepalive terminates after a missed pong and clears its interval', async (t) => {
|
|
41
|
-
t.mock.timers.enable({ apis: ['setInterval'] });
|
|
42
|
-
try {
|
|
43
|
-
const socket = new FakeWs();
|
|
44
|
-
proxyInternals.attachWsKeepalive(socket, { nodeId: 'node-2', tenantId: 'tenant-2', leg: 'upstream' }, { intervalMs: 1 });
|
|
45
|
-
t.mock.timers.tick(1);
|
|
46
|
-
assert.equal(socket.pingCount, 1);
|
|
47
|
-
assert.equal(socket.terminateCount, 0);
|
|
48
|
-
t.mock.timers.tick(1);
|
|
49
|
-
assert.equal(socket.pingCount, 1);
|
|
50
|
-
assert.equal(socket.terminateCount, 1);
|
|
51
|
-
assert.equal(socket.listenerCount('pong'), 0);
|
|
52
|
-
t.mock.timers.tick(10);
|
|
53
|
-
assert.equal(socket.pingCount, 1);
|
|
54
|
-
assert.equal(socket.terminateCount, 1);
|
|
55
|
-
}
|
|
56
|
-
finally {
|
|
57
|
-
t.mock.timers.reset();
|
|
58
|
-
}
|
|
59
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { mkdirSync, 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 { routeRequest } from '../server.js';
|
|
9
|
-
const ownerToken = 'owner-secret-token';
|
|
10
|
-
const ownerTokenHash = createHash('sha256').update(ownerToken, 'utf8').digest('hex');
|
|
11
|
-
const publicOrigin = 'https://hearth.example';
|
|
12
|
-
function makeRuntime(tenantId, statePath) {
|
|
13
|
-
return {
|
|
14
|
-
proxy: {
|
|
15
|
-
host: '127.0.0.1',
|
|
16
|
-
port: 7878,
|
|
17
|
-
publicOrigin,
|
|
18
|
-
statePath,
|
|
19
|
-
m0RegistryPath: join(statePath, 'homes.json'),
|
|
20
|
-
ownerUserId: 'owner-user',
|
|
21
|
-
defaultTenantId: tenantId,
|
|
22
|
-
ownerTokenHash,
|
|
23
|
-
idlePauseMs: 60_000,
|
|
24
|
-
wakeTimeoutMs: 30_000,
|
|
25
|
-
readyTimeoutMs: 2_000,
|
|
26
|
-
cookieSecure: true,
|
|
27
|
-
seedHome: null,
|
|
28
|
-
},
|
|
29
|
-
m0: {
|
|
30
|
-
providerName: 'none',
|
|
31
|
-
registryPath: join(statePath, 'homes.json'),
|
|
32
|
-
guestUser: 'agent',
|
|
33
|
-
guestHome: '/home/agent',
|
|
34
|
-
guestCodePath: '/opt/crouter',
|
|
35
|
-
webPort: 7878,
|
|
36
|
-
nodeKind: 'general',
|
|
37
|
-
piPackages: [],
|
|
38
|
-
piAuthJson: null,
|
|
39
|
-
relayToken: null,
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
function writeState(statePath, tenantId) {
|
|
44
|
-
mkdirSync(join(statePath, '..'), { recursive: true });
|
|
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 writeHome(registryPath, home) {
|
|
57
|
-
mkdirSync(join(registryPath, '..'), { recursive: true });
|
|
58
|
-
writeFileSync(registryPath, `${JSON.stringify({
|
|
59
|
-
[home.tenantId]: home,
|
|
60
|
-
}, null, 2)}\n`, 'utf8');
|
|
61
|
-
}
|
|
62
|
-
function makeRequest(method, url, headers = {}, body = '') {
|
|
63
|
-
const req = new Readable({ read() { } });
|
|
64
|
-
req.method = method;
|
|
65
|
-
req.url = url;
|
|
66
|
-
req.headers = headers;
|
|
67
|
-
process.nextTick(() => {
|
|
68
|
-
if (body !== '')
|
|
69
|
-
req.push(body);
|
|
70
|
-
req.push(null);
|
|
71
|
-
});
|
|
72
|
-
return req;
|
|
73
|
-
}
|
|
74
|
-
function makeResponse() {
|
|
75
|
-
return {
|
|
76
|
-
statusCode: 200,
|
|
77
|
-
headersSent: false,
|
|
78
|
-
headers: {},
|
|
79
|
-
body: '',
|
|
80
|
-
writeHead(statusCode, headers) {
|
|
81
|
-
this.statusCode = statusCode;
|
|
82
|
-
this.headers = { ...this.headers, ...headers };
|
|
83
|
-
this.headersSent = true;
|
|
84
|
-
return this;
|
|
85
|
-
},
|
|
86
|
-
setHeader(name, value) {
|
|
87
|
-
this.headers[name.toLowerCase()] = value;
|
|
88
|
-
},
|
|
89
|
-
end(chunk) {
|
|
90
|
-
if (chunk !== undefined)
|
|
91
|
-
this.body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
92
|
-
this.headersSent = true;
|
|
93
|
-
return this;
|
|
94
|
-
},
|
|
95
|
-
destroy() {
|
|
96
|
-
this.headersSent = true;
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
function parseJsonBody(res) {
|
|
101
|
-
return JSON.parse(res.body);
|
|
102
|
-
}
|
|
103
|
-
test('status route returns 401 without owner auth', async () => {
|
|
104
|
-
const tenantId = 'tenant-status-unauthenticated';
|
|
105
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-status-unauth-'));
|
|
106
|
-
const statePath = join(tempDir, 'state.json');
|
|
107
|
-
writeState(statePath, tenantId);
|
|
108
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
109
|
-
const res = makeResponse();
|
|
110
|
-
const handled = await routeRequest(makeRequest('GET', '/__hearth/status'), res, runtime, {});
|
|
111
|
-
assert.equal(handled, undefined);
|
|
112
|
-
assert.equal(res.statusCode, 401);
|
|
113
|
-
});
|
|
114
|
-
test('status route returns runtime stats when existing relay already serves', async () => {
|
|
115
|
-
const tenantId = 'tenant-status-runtime';
|
|
116
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-status-runtime-'));
|
|
117
|
-
const statePath = join(tempDir, 'state.json');
|
|
118
|
-
const registryPath = join(tempDir, 'homes.json');
|
|
119
|
-
writeState(statePath, tenantId);
|
|
120
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
121
|
-
runtime.proxy.m0RegistryPath = registryPath;
|
|
122
|
-
runtime.m0.registryPath = registryPath;
|
|
123
|
-
const capturedAt = new Date().toISOString();
|
|
124
|
-
const fetchCalls = [];
|
|
125
|
-
writeHome(registryPath, {
|
|
126
|
-
tenantId,
|
|
127
|
-
providerName: 'none',
|
|
128
|
-
machineId: 'test-machine',
|
|
129
|
-
homeStorage: {
|
|
130
|
-
kind: 'volume',
|
|
131
|
-
durability: 'durable-volume',
|
|
132
|
-
longTermContract: true,
|
|
133
|
-
ownerMachineId: 'test-machine',
|
|
134
|
-
guestHome: '/home/agent',
|
|
135
|
-
volumeId: 'volume-123',
|
|
136
|
-
mountPath: '/home/agent',
|
|
137
|
-
},
|
|
138
|
-
state: 'running',
|
|
139
|
-
nodeId: 'test-node',
|
|
140
|
-
relayToken: 'relay-token',
|
|
141
|
-
webRoute: {
|
|
142
|
-
routeId: 'https://relay.example',
|
|
143
|
-
machineId: 'test-machine',
|
|
144
|
-
targetPort: 7878,
|
|
145
|
-
url: 'https://relay.example',
|
|
146
|
-
concreteUrl: 'https://relay.example',
|
|
147
|
-
concreteUrlSource: 'config',
|
|
148
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
149
|
-
raw: null,
|
|
150
|
-
},
|
|
151
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
152
|
-
updatedAt: '2026-06-26T00:00:00.000Z',
|
|
153
|
-
});
|
|
154
|
-
const res = makeResponse();
|
|
155
|
-
const handled = await routeRequest(makeRequest('GET', '/__hearth/status', { authorization: `Bearer ${ownerToken}` }), res, runtime, {
|
|
156
|
-
status: {
|
|
157
|
-
fetch: (async (url, init) => {
|
|
158
|
-
fetchCalls.push({ url: String(url), init: init ?? {} });
|
|
159
|
-
return {
|
|
160
|
-
ok: true,
|
|
161
|
-
status: 200,
|
|
162
|
-
statusText: 'OK',
|
|
163
|
-
async text() {
|
|
164
|
-
return JSON.stringify({
|
|
165
|
-
ok: true,
|
|
166
|
-
exitCode: 0,
|
|
167
|
-
stdout: JSON.stringify({
|
|
168
|
-
cpuPct: 17.5,
|
|
169
|
-
memoryUsedMB: 612,
|
|
170
|
-
memoryAvailableMB: 1436,
|
|
171
|
-
crtrRssKB: 45000,
|
|
172
|
-
piRssKB: 32000,
|
|
173
|
-
nodeRssKB: 12000,
|
|
174
|
-
totalRssKB: 89000,
|
|
175
|
-
capturedAt,
|
|
176
|
-
}),
|
|
177
|
-
stderr: '',
|
|
178
|
-
});
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}),
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
assert.equal(handled, undefined);
|
|
185
|
-
assert.equal(res.statusCode, 200);
|
|
186
|
-
const body = parseJsonBody(res);
|
|
187
|
-
assert.equal(body.runtimeStats?.cpuPct, 17.5);
|
|
188
|
-
assert.equal(body.machineResources, null);
|
|
189
|
-
assert.equal(fetchCalls.length, 1);
|
|
190
|
-
assert.equal(fetchCalls[0]?.url, 'https://relay.example/__crtr/source');
|
|
191
|
-
assert.deepEqual(fetchCalls[0]?.init?.headers, {
|
|
192
|
-
authorization: 'Bearer relay-token',
|
|
193
|
-
'content-type': 'application/json',
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
test('status miss does not start wake/restore', async () => {
|
|
197
|
-
const tenantId = 'tenant-status-probe-fail';
|
|
198
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-status-probe-fail-'));
|
|
199
|
-
const statePath = join(tempDir, 'state.json');
|
|
200
|
-
const registryPath = join(tempDir, 'homes.json');
|
|
201
|
-
writeState(statePath, tenantId);
|
|
202
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
203
|
-
runtime.proxy.m0RegistryPath = registryPath;
|
|
204
|
-
runtime.m0.registryPath = registryPath;
|
|
205
|
-
writeHome(registryPath, {
|
|
206
|
-
tenantId,
|
|
207
|
-
providerName: 'none',
|
|
208
|
-
machineId: 'test-machine',
|
|
209
|
-
homeStorage: {
|
|
210
|
-
kind: 'volume',
|
|
211
|
-
durability: 'durable-volume',
|
|
212
|
-
longTermContract: true,
|
|
213
|
-
ownerMachineId: 'test-machine',
|
|
214
|
-
guestHome: '/home/agent',
|
|
215
|
-
volumeId: 'volume-123',
|
|
216
|
-
mountPath: '/home/agent',
|
|
217
|
-
},
|
|
218
|
-
state: 'running',
|
|
219
|
-
nodeId: 'test-node',
|
|
220
|
-
relayToken: 'relay-token',
|
|
221
|
-
webRoute: {
|
|
222
|
-
routeId: 'https://relay.example',
|
|
223
|
-
machineId: 'test-machine',
|
|
224
|
-
targetPort: 7878,
|
|
225
|
-
url: 'https://relay.example',
|
|
226
|
-
concreteUrl: 'https://relay.example',
|
|
227
|
-
concreteUrlSource: 'config',
|
|
228
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
229
|
-
raw: null,
|
|
230
|
-
},
|
|
231
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
232
|
-
updatedAt: '2026-06-26T00:00:00.000Z',
|
|
233
|
-
});
|
|
234
|
-
let guestSourceCalls = 0;
|
|
235
|
-
const fetchCalls = [];
|
|
236
|
-
const res = makeResponse();
|
|
237
|
-
const handled = await routeRequest(makeRequest('GET', '/__hearth/status', { authorization: `Bearer ${ownerToken}` }), res, runtime, {
|
|
238
|
-
status: {
|
|
239
|
-
fetch: (async (url, init) => {
|
|
240
|
-
fetchCalls.push({ url: String(url), init: init ?? {} });
|
|
241
|
-
return {
|
|
242
|
-
ok: false,
|
|
243
|
-
status: 503,
|
|
244
|
-
statusText: 'Service Unavailable',
|
|
245
|
-
async text() {
|
|
246
|
-
return 'relay is down';
|
|
247
|
-
},
|
|
248
|
-
};
|
|
249
|
-
}),
|
|
250
|
-
guestSource: async () => {
|
|
251
|
-
guestSourceCalls += 1;
|
|
252
|
-
throw new Error('guestSource should not run on a status miss');
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
});
|
|
256
|
-
assert.equal(handled, undefined);
|
|
257
|
-
assert.equal(res.statusCode, 200);
|
|
258
|
-
const body = parseJsonBody(res);
|
|
259
|
-
assert.equal(body.runtimeStats, null);
|
|
260
|
-
assert.equal(fetchCalls.length, 1);
|
|
261
|
-
assert.equal(fetchCalls[0]?.url, 'https://relay.example/__crtr/source');
|
|
262
|
-
assert.equal(guestSourceCalls, 0);
|
|
263
|
-
});
|
|
264
|
-
test('status route returns null runtime stats when the probe output is invalid JSON', async () => {
|
|
265
|
-
const tenantId = 'tenant-status-parse-fail';
|
|
266
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-status-parse-fail-'));
|
|
267
|
-
const statePath = join(tempDir, 'state.json');
|
|
268
|
-
const registryPath = join(tempDir, 'homes.json');
|
|
269
|
-
writeState(statePath, tenantId);
|
|
270
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
271
|
-
runtime.proxy.m0RegistryPath = registryPath;
|
|
272
|
-
runtime.m0.registryPath = registryPath;
|
|
273
|
-
writeHome(registryPath, {
|
|
274
|
-
tenantId,
|
|
275
|
-
providerName: 'none',
|
|
276
|
-
machineId: 'test-machine',
|
|
277
|
-
homeStorage: {
|
|
278
|
-
kind: 'volume',
|
|
279
|
-
durability: 'durable-volume',
|
|
280
|
-
longTermContract: true,
|
|
281
|
-
ownerMachineId: 'test-machine',
|
|
282
|
-
guestHome: '/home/agent',
|
|
283
|
-
volumeId: 'volume-123',
|
|
284
|
-
mountPath: '/home/agent',
|
|
285
|
-
},
|
|
286
|
-
state: 'running',
|
|
287
|
-
nodeId: 'test-node',
|
|
288
|
-
relayToken: 'relay-token',
|
|
289
|
-
webRoute: {
|
|
290
|
-
routeId: 'https://relay.example',
|
|
291
|
-
machineId: 'test-machine',
|
|
292
|
-
targetPort: 7878,
|
|
293
|
-
url: 'https://relay.example',
|
|
294
|
-
concreteUrl: 'https://relay.example',
|
|
295
|
-
concreteUrlSource: 'config',
|
|
296
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
297
|
-
raw: null,
|
|
298
|
-
},
|
|
299
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
300
|
-
updatedAt: '2026-06-26T00:00:00.000Z',
|
|
301
|
-
});
|
|
302
|
-
const res = makeResponse();
|
|
303
|
-
const handled = await routeRequest(makeRequest('GET', '/__hearth/status', { authorization: `Bearer ${ownerToken}` }), res, runtime, {
|
|
304
|
-
status: {
|
|
305
|
-
fetch: (async () => ({
|
|
306
|
-
ok: true,
|
|
307
|
-
status: 200,
|
|
308
|
-
statusText: 'OK',
|
|
309
|
-
async text() {
|
|
310
|
-
return 'not json';
|
|
311
|
-
},
|
|
312
|
-
})),
|
|
313
|
-
},
|
|
314
|
-
});
|
|
315
|
-
assert.equal(handled, undefined);
|
|
316
|
-
assert.equal(res.statusCode, 200);
|
|
317
|
-
const body = parseJsonBody(res);
|
|
318
|
-
assert.equal(body.runtimeStats, null);
|
|
319
|
-
});
|
|
320
|
-
test('status route returns machine resources from getMachine', async () => {
|
|
321
|
-
const tenantId = 'tenant-status-machine';
|
|
322
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'crouter-hearth-status-machine-'));
|
|
323
|
-
const statePath = join(tempDir, 'state.json');
|
|
324
|
-
const registryPath = join(tempDir, 'homes.json');
|
|
325
|
-
writeState(statePath, tenantId);
|
|
326
|
-
writeFileSync(registryPath, `${JSON.stringify({
|
|
327
|
-
[tenantId]: {
|
|
328
|
-
tenantId,
|
|
329
|
-
providerName: 'none',
|
|
330
|
-
machineId: 'test-machine',
|
|
331
|
-
homeStorage: {
|
|
332
|
-
kind: 'volume',
|
|
333
|
-
durability: 'durable-volume',
|
|
334
|
-
longTermContract: true,
|
|
335
|
-
ownerMachineId: 'test-machine',
|
|
336
|
-
guestHome: '/home/agent',
|
|
337
|
-
volumeId: 'volume-123',
|
|
338
|
-
mountPath: '/home/agent',
|
|
339
|
-
},
|
|
340
|
-
state: 'running',
|
|
341
|
-
createdAt: '2026-06-26T00:00:00.000Z',
|
|
342
|
-
updatedAt: '2026-06-26T00:00:00.000Z',
|
|
343
|
-
},
|
|
344
|
-
}, null, 2)}\n`, 'utf8');
|
|
345
|
-
const runtime = makeRuntime(tenantId, statePath);
|
|
346
|
-
runtime.proxy.m0RegistryPath = registryPath;
|
|
347
|
-
runtime.m0.registryPath = registryPath;
|
|
348
|
-
const getMachine = async (machineId) => {
|
|
349
|
-
assert.equal(machineId, 'test-machine');
|
|
350
|
-
return {
|
|
351
|
-
providerName: 'none',
|
|
352
|
-
machineId: 'test-machine',
|
|
353
|
-
state: 'running',
|
|
354
|
-
raw: {
|
|
355
|
-
cpuCount: 2,
|
|
356
|
-
memoryMB: 2048,
|
|
357
|
-
status: 'running',
|
|
358
|
-
},
|
|
359
|
-
};
|
|
360
|
-
};
|
|
361
|
-
const res = makeResponse();
|
|
362
|
-
const handled = await routeRequest(makeRequest('GET', '/__hearth/status', { authorization: `Bearer ${ownerToken}` }), res, runtime, {
|
|
363
|
-
status: {
|
|
364
|
-
getMachine,
|
|
365
|
-
guestSource: async () => ({ ok: false, stdout: '', stderr: 'timeout' }),
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
assert.equal(handled, undefined);
|
|
369
|
-
assert.equal(res.statusCode, 200);
|
|
370
|
-
const body = parseJsonBody(res);
|
|
371
|
-
assert.deepEqual(body.machineResources, { cpuCount: 2, memoryMB: 2048 });
|
|
372
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|