@crouton-kit/crouter 0.3.39 → 0.3.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -1,136 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { buildBootstrapScript } from '../hearth/providers/blaxel-bootstrap.js';
4
- const FIRST_BOOT_OPTS = {
5
- guestHome: '/home/agent',
6
- crtrVersion: '0.3.33',
7
- relayToken: 'relay-token',
8
- webPort: 7878,
9
- piAuthJson: '{"anthropic":{"type":"oauth","refresh":"r","access":"a","expires":1}}',
10
- piPackages: ['npm:pi-claude-oauth-adapter@0.1.2'],
11
- modelLaddersJson: '{"modelLadders":{"anthropic":{"strong":"anthropic/claude-opus-4-8:high"},"openai":{"strong":"openai-codex/gpt-5.5:high"},"defaultProvider":"anthropic"}}',
12
- isFirstProvision: true,
13
- };
14
- function indexOf(script, needle) {
15
- const i = script.indexOf(needle);
16
- assert.ok(i !== -1, `expected to find ${JSON.stringify(needle)} in script`);
17
- return i;
18
- }
19
- test('first provision seeds config/ladders and pi settings, never the rotation pool, and never installs', () => {
20
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
21
- // Seeds that DO belong (config template + model ladders + pi settings +
22
- // optional single auth.json).
23
- assert.match(script, /settings\.json/);
24
- assert.match(script, /pi-crtr-extensions/);
25
- assert.match(script, /CRTR_CONFIG_JSON/);
26
- assert.match(script, /PI_AUTH_JSON/);
27
- // The rotation pool is dead product — bridge/migration only, never emitted
28
- // by the rewritten bootstrap regardless of opts shape.
29
- assert.doesNotMatch(script, /ANTHROPIC_SUBSCRIPTIONS_JSON/);
30
- assert.doesNotMatch(script, /OPENAI_CODEX_SUBSCRIPTIONS_JSON/);
31
- assert.doesNotMatch(script, /PROVIDER_ROTATION_JSON/);
32
- assert.doesNotMatch(script, /anthropic-subscriptions\.json/);
33
- assert.doesNotMatch(script, /openai-codex-subscriptions\.json/);
34
- assert.doesNotMatch(script, /provider-rotation\.json/);
35
- // No boot-time install of any kind — the image bakes crtr; bootstrap only
36
- // asserts it.
37
- assert.doesNotMatch(script, /npm install/);
38
- });
39
- test('recreate (isFirstProvision:false) emits no seeds at all and revives the existing node', () => {
40
- const script = buildBootstrapScript({
41
- ...FIRST_BOOT_OPTS,
42
- isFirstProvision: false,
43
- nodeId: 'node-123',
44
- });
45
- assert.doesNotMatch(script, /settings\.json/);
46
- assert.doesNotMatch(script, /CRTR_CONFIG_JSON/);
47
- assert.doesNotMatch(script, /PI_AUTH_JSON/);
48
- assert.doesNotMatch(script, /ANTHROPIC_SUBSCRIPTIONS_JSON/);
49
- assert.doesNotMatch(script, /OPENAI_CODEX_SUBSCRIPTIONS_JSON/);
50
- assert.doesNotMatch(script, /PROVIDER_ROTATION_JSON/);
51
- assert.match(script, /'\/usr\/local\/bin\/crtr' canvas revive 'node-123'/);
52
- assert.doesNotMatch(script, /node new --root/);
53
- });
54
- test('recreate without a nodeId fails fast at script-generation time, not inside the guest', () => {
55
- assert.throws(() => buildBootstrapScript({ ...FIRST_BOOT_OPTS, isFirstProvision: false, nodeId: null }));
56
- assert.throws(() => buildBootstrapScript({ ...FIRST_BOOT_OPTS, isFirstProvision: false, nodeId: ' ' }));
57
- });
58
- test('an empty relay token fails fast at script-generation time — the web lane must never bind unauthenticated', () => {
59
- assert.throws(() => buildBootstrapScript({ ...FIRST_BOOT_OPTS, relayToken: '' }));
60
- assert.throws(() => buildBootstrapScript({ ...FIRST_BOOT_OPTS, relayToken: ' ' }));
61
- });
62
- test('asserts the baked crtr command surface — never falls back to the retired top-level alias', () => {
63
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
64
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web -h/);
65
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve -h/);
66
- assert.match(script, /command surface assertion FAILED/);
67
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve --host 0\.0\.0\.0/);
68
- // Never the retired top-level alias, anywhere in the script (e.g. as a
69
- // fallback if the surface assertion fails).
70
- assert.doesNotMatch(script, /[^/]crtr web serve/);
71
- });
72
- test('asserts the pinned crtr version against the baked binary and fails closed on mismatch', () => {
73
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
74
- assert.match(script, /'\/usr\/local\/bin\/crtr' --json sys version/);
75
- assert.match(script, /crtr version assertion FAILED/);
76
- assert.match(script, /!= '0\.3\.33'/);
77
- });
78
- test('every web-serve invocation carries a non-empty --token, enforcing relay-token auth on every lane', () => {
79
- const firstBoot = buildBootstrapScript(FIRST_BOOT_OPTS);
80
- const recreate = buildBootstrapScript({ ...FIRST_BOOT_OPTS, isFirstProvision: false, nodeId: 'node-123' });
81
- for (const script of [firstBoot, recreate]) {
82
- const serveLine = script.split('\n').find((line) => line.includes('surface web serve --host'));
83
- assert.ok(serveLine, 'expected a crtr surface web serve invocation');
84
- assert.match(serveLine, /--token 'relay-token'/);
85
- }
86
- });
87
- test('every crtr invocation targets the baked binary by absolute path, never a bare PATH lookup, defeating a stale volume-installed crtr shadowing it (review Major 1)', () => {
88
- const firstBoot = buildBootstrapScript(FIRST_BOOT_OPTS);
89
- const recreate = buildBootstrapScript({ ...FIRST_BOOT_OPTS, isFirstProvision: false, nodeId: 'node-123' });
90
- for (const script of [firstBoot, recreate]) {
91
- assert.match(script, /'\/usr\/local\/bin\/crtr' --json sys version/);
92
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web -h/);
93
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve -h/);
94
- assert.match(script, /'\/usr\/local\/bin\/crtr' sys daemon start/);
95
- assert.match(script, /'\/usr\/local\/bin\/crtr' surface web serve --host 0\.0\.0\.0/);
96
- // No bare `crtr <subcommand>` invocation anywhere — only the `command -v
97
- // crtr` shadow-detection probe and human-readable error-message text are
98
- // allowed to reference it by bare name.
99
- const bareInvocations = script
100
- .split('\n')
101
- .filter((line) => !line.trim().startsWith('printf'))
102
- .filter((line) => /(^|[\s(])crtr (canvas|surface|--json)/.test(line));
103
- assert.deepEqual(bareInvocations, []);
104
- }
105
- assert.match(firstBoot, /'\/usr\/local\/bin\/crtr' --json node new --root/);
106
- });
107
- test('asserts a bare `crtr` PATH lookup resolves to the baked binary, catching a stale volume-installed crtr shadowing it', () => {
108
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
109
- assert.match(script, /resolved_crtr="\$\(command -v crtr 2>\/dev\/null \|\| true\)"/);
110
- assert.match(script, /if \[ "\$resolved_crtr" != '\/usr\/local\/bin\/crtr' \]; then/);
111
- assert.match(script, /stale crtr shadowing baked binary/);
112
- });
113
- test('exports a PATH with the baked /usr/local/bin ahead of the legacy (dead) per-home runtime bin dir', () => {
114
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
115
- assert.match(script, /export PATH='\/usr\/local\/bin:\/usr\/bin:\/bin'/);
116
- assert.doesNotMatch(script, /crtr-runtime\/bin/);
117
- });
118
- test('mkdirs the tmpfs CRTR_SOCK_DIR/CRTR_PIDFILE dirs strictly before the daemon starts, and CRTR_CANVAS_DB stays unset', () => {
119
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
120
- assert.match(script, /export CRTR_SOCK_DIR='\/run\/crtr\/socks'/);
121
- assert.match(script, /export CRTR_PIDFILE='\/run\/crtr\/crtrd\.pid'/);
122
- assert.match(script, /export CRTR_HOME='\/home\/agent\/\.crouter\/canvas'/);
123
- assert.doesNotMatch(script, /CRTR_CANVAS_DB=/);
124
- assert.match(script, /unset CRTR_CANVAS_DB/);
125
- const mkdirIdx = indexOf(script, '/run/crtr/socks');
126
- const daemonStartIdx = indexOf(script, "'/usr/local/bin/crtr' sys daemon start");
127
- assert.ok(mkdirIdx < daemonStartIdx, 'tmpfs mkdir must precede daemon start');
128
- const mkdirLine = script.split('\n').find((line) => line.startsWith('mkdir -p') && line.includes('/run/crtr/socks'));
129
- assert.ok(mkdirLine, 'expected a single mkdir line covering the tmpfs dirs');
130
- });
131
- test('primes the baked pi Claude OAuth adapter into the npm cache path pi resolves, existence-gated', () => {
132
- const script = buildBootstrapScript(FIRST_BOOT_OPTS);
133
- assert.match(script, /\/opt\/crouter\/node_modules\/pi-claude-oauth-adapter/);
134
- assert.match(script, /\.pi\/agent\/npm\/node_modules\/pi-claude-oauth-adapter/);
135
- assert.match(script, /cp -R/);
136
- });
@@ -1,151 +0,0 @@
1
- // model-auth-guest.test.ts — focused P3.3b in-guest model-auth lane regression
2
- // test, added under Silas's 2026-06-30 category-3 Hearth/control-plane
3
- // carve-out (CLAUDE.md testing policy) for the load-bearing invariants named
4
- // in the P3.3b implementation review
5
- // (.crouter/canvas/nodes/mr1exjvw-73c75466/context/p3p3b-in-guest-model-auth-review.md,
6
- // "Minor — no ported unit test for the in-guest lane"):
7
- //
8
- // 1. Tokenless/invalid-token access to the model-auth lane fails closed with
9
- // 401 (strict relay-token gate; no loopback bypass) — the design-review
10
- // Major that the lane must use `requestTokenMatches`, never
11
- // `requestAllowed()` (which would accept a tokenless loopback request).
12
- // 2. `status` returns 200 with a correct unauthenticated/authenticated body
13
- // when there is exactly one pi `auth.json` in the intended store — locks
14
- // in the fix for the old M0 wake-proxy 502 bug class.
15
- // 3. No response body ever includes access/refresh credential material.
16
- //
17
- // This drives the real seam end-to-end: `startWebServer` (src/clients/web/
18
- // server.ts) mounts `handleHearthModelAuthRequest`
19
- // (../model-auth-guest.ts) behind its relay-token gate, and the test points
20
- // pi's real `AuthStorage` at a temp dir via `PI_CODING_AGENT_DIR` (the same
21
- // env var `getAgentDir()` honors in production) so the lane reads/writes a
22
- // real single `auth.json` with no mocking of the module under test.
23
- import assert from 'node:assert/strict';
24
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25
- import { tmpdir } from 'node:os';
26
- import { join } from 'node:path';
27
- import { test } from 'node:test';
28
- import { AuthStorage } from '@earendil-works/pi-coding-agent';
29
- import { startWebServer } from '../../../clients/web/server.js';
30
- const RELAY_TOKEN = 'model-auth-test-relay-token';
31
- const STATUS_PATH = '/__hearth/model-auth/anthropic/status';
32
- const START_PATH = '/__hearth/model-auth/anthropic/start';
33
- const FINISH_PATH = '/__hearth/model-auth/anthropic/finish';
34
- async function withServer(fn) {
35
- const server = await startWebServer({ port: 0, host: '127.0.0.1', token: RELAY_TOKEN });
36
- try {
37
- await fn(server);
38
- }
39
- finally {
40
- await server.close();
41
- }
42
- }
43
- /** Point pi's AuthStorage at a fresh temp dir (the same env var `getAgentDir()`
44
- * reads in production) for the duration of `fn`, then restore the prior
45
- * value. Returns the temp dir so a caller can seed `auth.json` before use. */
46
- async function withTempAgentDir(fn) {
47
- const agentDir = mkdtempSync(join(tmpdir(), 'model-auth-guest-test-'));
48
- const prior = process.env['PI_CODING_AGENT_DIR'];
49
- process.env['PI_CODING_AGENT_DIR'] = agentDir;
50
- try {
51
- await fn(agentDir);
52
- }
53
- finally {
54
- if (prior === undefined) {
55
- delete process.env['PI_CODING_AGENT_DIR'];
56
- }
57
- else {
58
- process.env['PI_CODING_AGENT_DIR'] = prior;
59
- }
60
- rmSync(agentDir, { recursive: true, force: true });
61
- }
62
- }
63
- test('model-auth lane fails closed with 401 for tokenless and invalid-token requests (no loopback bypass)', async () => {
64
- await withTempAgentDir(async (agentDir) => {
65
- // Exactly one auth.json present — proves the 401 is the token gate, not a
66
- // downstream store-resolution failure.
67
- writeFileSync(join(agentDir, 'auth.json'), '{}\n', 'utf8');
68
- await withServer(async (server) => {
69
- // No credentials at all, on loopback — this is the design-review Major:
70
- // the lane must use the strict `requestTokenMatches` predicate, never
71
- // `requestAllowed()`, which accepts a tokenless loopback request for every
72
- // OTHER route on this same server.
73
- for (const [method, path] of [
74
- ['GET', STATUS_PATH],
75
- ['POST', START_PATH],
76
- ['POST', FINISH_PATH],
77
- ]) {
78
- const res = await fetch(`${server.url}${path}`, { method });
79
- assert.equal(res.status, 401, `${method} ${path} with no token`);
80
- }
81
- // Wrong bearer token.
82
- const wrongBearer = await fetch(`${server.url}${STATUS_PATH}`, {
83
- headers: { authorization: 'Bearer not-the-relay-token' },
84
- });
85
- assert.equal(wrongBearer.status, 401);
86
- // Wrong query token.
87
- const wrongQuery = await fetch(`${server.url}${STATUS_PATH}?token=not-the-relay-token`);
88
- assert.equal(wrongQuery.status, 401);
89
- // Sanity: the SAME server, SAME loopback bind, with the correct token
90
- // succeeds — proves the 401s above are the gate rejecting bad
91
- // credentials, not some other failure making every request fail.
92
- const correct = await fetch(`${server.url}${STATUS_PATH}`, {
93
- headers: { authorization: `Bearer ${RELAY_TOKEN}` },
94
- });
95
- assert.equal(correct.status, 200);
96
- });
97
- });
98
- });
99
- test('model-auth status returns 200 with correct unauthenticated/authenticated state for a single auth.json (M0 502 regression)', async () => {
100
- await withServer(async (server) => {
101
- // Unauthenticated: exactly one auth.json, no anthropic credential stored.
102
- await withTempAgentDir(async (agentDir) => {
103
- writeFileSync(join(agentDir, 'auth.json'), '{}\n', 'utf8');
104
- const res = await fetch(`${server.url}${STATUS_PATH}`, {
105
- headers: { authorization: `Bearer ${RELAY_TOKEN}` },
106
- });
107
- assert.equal(res.status, 200);
108
- const body = (await res.json());
109
- assert.deepEqual(body, { storedOauth: false, expiresAt: null, adapterConfigured: true });
110
- });
111
- // Authenticated: exactly one auth.json, with a real stored anthropic oauth
112
- // credential written through the real AuthStorage (not hand-crafted JSON).
113
- await withTempAgentDir(async () => {
114
- const storage = AuthStorage.create();
115
- storage.set('anthropic', { type: 'oauth', access: 'access-secret', refresh: 'refresh-secret', expires: 4_102_444_800_000 });
116
- assert.deepEqual(storage.drainErrors(), []);
117
- const res = await fetch(`${server.url}${STATUS_PATH}`, {
118
- headers: { authorization: `Bearer ${RELAY_TOKEN}` },
119
- });
120
- assert.equal(res.status, 200);
121
- const body = (await res.json());
122
- assert.deepEqual(body, { storedOauth: true, expiresAt: 4_102_444_800_000, adapterConfigured: true });
123
- });
124
- });
125
- });
126
- test('model-auth status response never includes access/refresh credential material', async () => {
127
- await withServer(async (server) => {
128
- await withTempAgentDir(async () => {
129
- const storage = AuthStorage.create();
130
- storage.set('anthropic', {
131
- type: 'oauth',
132
- access: 'super-secret-access-token',
133
- refresh: 'super-secret-refresh-token',
134
- expires: 4_102_444_800_000,
135
- });
136
- assert.deepEqual(storage.drainErrors(), []);
137
- const res = await fetch(`${server.url}${STATUS_PATH}`, {
138
- headers: { authorization: `Bearer ${RELAY_TOKEN}` },
139
- });
140
- assert.equal(res.status, 200);
141
- const rawText = await res.text();
142
- const body = JSON.parse(rawText);
143
- // The response is exactly the redacted status shape — no access/refresh
144
- // keys or raw secret material anywhere in the body.
145
- assert.deepEqual(Object.keys(body).sort(), ['adapterConfigured', 'expiresAt', 'storedOauth']);
146
- assert.equal(rawText.includes('super-secret-access-token'), false);
147
- assert.equal(rawText.includes('super-secret-refresh-token'), false);
148
- assert.equal(/access|refresh/i.test(rawText), false);
149
- });
150
- });
151
- });
@@ -1,3 +0,0 @@
1
- import type { M0Config } from './types.js';
2
- export declare function hearthHomeImageRefForVersion(version: string): string;
3
- export declare function loadM0Config(env?: NodeJS.ProcessEnv): M0Config;
@@ -1,108 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { general } from '../errors.js';
5
- const ENV = {
6
- provider: 'CRTR_HEARTH_PROVIDER',
7
- guestUser: 'CRTR_HEARTH_M0_GUEST_USER',
8
- guestHome: 'CRTR_HEARTH_M0_GUEST_HOME',
9
- guestCode: 'CRTR_HEARTH_M0_GUEST_CODE',
10
- webPort: 'CRTR_HEARTH_M0_WEB_PORT',
11
- image: 'CRTR_HEARTH_M0_IMAGE',
12
- templateVersion: 'CRTR_HEARTH_M0_TEMPLATE_VERSION',
13
- volumeSizeGb: 'CRTR_HEARTH_M0_VOLUME_SIZE_GB',
14
- nodeKind: 'CRTR_HEARTH_M0_NODE_KIND',
15
- piPackages: 'CRTR_HEARTH_M0_PI_PACKAGES',
16
- piAuthJson: 'CRTR_HEARTH_M0_PI_AUTH_JSON',
17
- relayToken: 'CRTR_HEARTH_M0_RELAY_TOKEN',
18
- };
19
- function expandHomePath(input) {
20
- if (input.startsWith('~'))
21
- return join(homedir(), input.slice(1));
22
- return resolve(input);
23
- }
24
- function parsePort(value, fallback, name) {
25
- if (value === undefined || value === '')
26
- return fallback;
27
- const parsed = Number(value);
28
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
29
- throw general(`invalid ${name}: ${value}`, { env: name });
30
- }
31
- return parsed;
32
- }
33
- function parseVolumeSizeGb(value, fallback, name) {
34
- if (value === undefined || value === '')
35
- return fallback;
36
- const parsed = Number(value);
37
- if (!Number.isInteger(parsed) || parsed < 4) {
38
- throw general(`invalid ${name}: ${value}`, { env: name, minimumGb: 4 });
39
- }
40
- return parsed;
41
- }
42
- function readCrtrVersion() {
43
- const file = new URL('../../../package.json', import.meta.url);
44
- const pkg = JSON.parse(readFileSync(file, 'utf8'));
45
- if (typeof pkg.version !== 'string' || pkg.version.trim() === '') {
46
- throw general('missing crtr package version', { packageJson: file.pathname });
47
- }
48
- return pkg.version;
49
- }
50
- export function hearthHomeImageRefForVersion(version) {
51
- return `crouter-hearth-home-${version.replace(/\./g, '-')}`;
52
- }
53
- const SAFE_PI_PACKAGE_SPEC = /^[A-Za-z0-9@/_:.-]+$/;
54
- const DEFAULT_PI_PACKAGES = ['npm:pi-claude-oauth-adapter@0.1.2'];
55
- function parsePiPackages(env) {
56
- const raw = env[ENV.piPackages]?.trim();
57
- if (raw === undefined || raw === '')
58
- return [...DEFAULT_PI_PACKAGES];
59
- const tokens = raw.split(/[,\s]+/).map((token) => token.trim()).filter((token) => token !== '');
60
- if (tokens.length === 0) {
61
- throw general(`invalid ${ENV.piPackages}: ${raw}`, { env: ENV.piPackages });
62
- }
63
- if (tokens.length === 1 && tokens[0]?.toLowerCase() === 'none')
64
- return [];
65
- if (tokens.some((token) => token.toLowerCase() === 'none')) {
66
- throw general(`invalid ${ENV.piPackages}: none must be the only value`, { env: ENV.piPackages });
67
- }
68
- for (const spec of tokens) {
69
- if (!SAFE_PI_PACKAGE_SPEC.test(spec)) {
70
- throw general(`invalid ${ENV.piPackages}: ${spec}`, { env: ENV.piPackages, packageSpec: spec });
71
- }
72
- }
73
- return tokens;
74
- }
75
- export function loadM0Config(env = process.env) {
76
- const providerName = (env[ENV.provider] ?? '').trim().toLowerCase();
77
- const guestUser = (env[ENV.guestUser] ?? 'agent').trim();
78
- const guestHome = expandHomePath(env[ENV.guestHome] ?? '/home/agent');
79
- const guestCodePath = expandHomePath(env[ENV.guestCode] ?? '/opt/crouter');
80
- const piPackages = parsePiPackages(env);
81
- const webPort = parsePort(env[ENV.webPort], 7878, ENV.webPort);
82
- const templateVersion = (env[ENV.templateVersion] ?? readCrtrVersion()).trim();
83
- if (templateVersion === '') {
84
- throw general(`invalid ${ENV.templateVersion}: empty`, { env: ENV.templateVersion });
85
- }
86
- const imageRef = (env[ENV.image] ?? hearthHomeImageRefForVersion(templateVersion)).trim();
87
- if (imageRef === '') {
88
- throw general(`invalid ${ENV.image}: empty`, { env: ENV.image });
89
- }
90
- const volumeSizeGb = parseVolumeSizeGb(env[ENV.volumeSizeGb], 4, ENV.volumeSizeGb);
91
- const nodeKind = (env[ENV.nodeKind] ?? 'general').trim();
92
- const piAuthJson = env[ENV.piAuthJson]?.trim() || null;
93
- const relayToken = env[ENV.relayToken]?.trim() || null;
94
- return {
95
- providerName,
96
- guestUser,
97
- guestHome,
98
- guestCodePath,
99
- piPackages,
100
- webPort,
101
- imageRef,
102
- templateVersion,
103
- volumeSizeGb,
104
- nodeKind,
105
- piAuthJson,
106
- relayToken,
107
- };
108
- }
@@ -1,9 +0,0 @@
1
- import type { M0Config } from './types.js';
2
- /** The complete home env table per R-DUR-8 — the one place that owns it.
3
- * `HOME`/`CRTR_HOME` resolve onto the durable user volume (canvas/auth/config
4
- * state survives destroy→recreate there); `CRTR_SOCK_DIR`/`CRTR_PIDFILE`
5
- * resolve onto tmpfs (rebound fresh every boot, never stale-on-volume);
6
- * `CRTR_CANVAS_DB` stays UNSET so `canvas.db` follows `CRTR_HOME` onto the
7
- * volume (its fcntl/WAL locks need real local-disk semantics, which the
8
- * block volume provides — unlike the NFS case `CRTR_CANVAS_DB` exists for). */
9
- export declare function guestEnv(config: M0Config): Record<string, string>;
@@ -1,27 +0,0 @@
1
- import { join } from 'node:path';
2
- // tmpfs base for the home's socket + pidfile (R-DUR-8/R-HOME-3): a unix
3
- // socket cannot bind on the durable NFS/block volume and a pidfile must never
4
- // be stale-on-volume across destroy/recreate, so both live process-local on
5
- // tmpfs instead. `/run` is the conventional tmpfs mount on the baked Blaxel
6
- // image; the bootstrap chain (blaxel-bootstrap.ts) mkdirs this before the
7
- // daemon starts.
8
- const GUEST_TMPFS_BASE = '/run/crtr';
9
- /** The complete home env table per R-DUR-8 — the one place that owns it.
10
- * `HOME`/`CRTR_HOME` resolve onto the durable user volume (canvas/auth/config
11
- * state survives destroy→recreate there); `CRTR_SOCK_DIR`/`CRTR_PIDFILE`
12
- * resolve onto tmpfs (rebound fresh every boot, never stale-on-volume);
13
- * `CRTR_CANVAS_DB` stays UNSET so `canvas.db` follows `CRTR_HOME` onto the
14
- * volume (its fcntl/WAL locks need real local-disk semantics, which the
15
- * block volume provides — unlike the NFS case `CRTR_CANVAS_DB` exists for). */
16
- export function guestEnv(config) {
17
- const home = config.guestHome;
18
- return {
19
- HOME: home,
20
- CRTR_HOME: join(home, '.crouter', 'canvas'),
21
- CRTR_SOCK_DIR: join(GUEST_TMPFS_BASE, 'socks'),
22
- CRTR_PIDFILE: join(GUEST_TMPFS_BASE, 'crtrd.pid'),
23
- PI_CODING_AGENT_DIR: join(home, '.pi', 'agent'),
24
- PATH: `${join(home, '.crtr-runtime', 'bin')}:/usr/local/bin:/usr/bin:/bin`,
25
- SANDBOX_DISABLE_PROCESS_LOGGING: 'true',
26
- };
27
- }
@@ -1,4 +0,0 @@
1
- export * from './types.js';
2
- export { loadM0Config } from './config.js';
3
- export { getHomeBackend, type HomeBackend } from './provider.js';
4
- export { guestEnv } from './guest-env.js';
@@ -1,4 +0,0 @@
1
- export * from './types.js';
2
- export { loadM0Config } from './config.js';
3
- export { getHomeBackend } from './provider.js';
4
- export { guestEnv } from './guest-env.js';
@@ -1,8 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- /** Dispatch one `/__hearth/model-auth/anthropic/{status,start,finish}` request.
3
- * The caller (src/clients/web/server.ts) has already matched the path prefix
4
- * and enforced the relay token — this function assumes both are done and
5
- * handles ONLY routing-within-the-lane, method checks, and the Hearth/OAuth
6
- * product logic. Always writes a response; never throws (errors become a
7
- * redacted 502/500 JSON body). */
8
- export declare function handleHearthModelAuthRequest(req: IncomingMessage, res: ServerResponse): Promise<void>;