@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,170 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { test } from 'node:test';
6
- // Category-3 test (CLAUDE.md "Testing policy" #3 — load-bearing Hearth/CP
7
- // security regression). Invariants: master plan P5.2/P5.3, part-plan T-B2/T-B4,
8
- // R-TRIG-1/R-TRIG-4. (1) The delivery chokepoint carries the envelope on STDIN
9
- // via the P4.4 primitive, never in argv — and only for an AUTHORIZED trigger.
10
- // (2) The FK-integrity ACL rejects cross-home targeting and terminated homes.
11
- // (3) An unauthorized trigger NEVER execs and writes a `rejected` audit row; an
12
- // authorized one writes a `delivered` row. Silent regressions here defeat the
13
- // R-TRIG-4 auth/audit boundary that all cron+webhook delivery funnels through.
14
- let dbDir;
15
- function setupDb() {
16
- dbDir = mkdtempSync(join(tmpdir(), 'crtr-cp-trigger-'));
17
- process.env['CRTR_HEARTH_CP_DB_PATH'] = join(dbDir, 'control-plane.db');
18
- }
19
- function teardownDb(dbPath) {
20
- // Lazy import to avoid pinning the module before env is set.
21
- void dbPath;
22
- rmSync(dbDir, { recursive: true, force: true });
23
- delete process.env['CRTR_HEARTH_CP_DB_PATH'];
24
- }
25
- test('deliverTrigger carries the serialized envelope on stdin at the requested tier for an authorized trigger', async () => {
26
- const { deliverTrigger, deliverInternals } = await import('../triggers/deliver.js');
27
- const { buildTriggerEnvelope, serializeEnvelope } = await import('../triggers/envelope.js');
28
- const orig = { ...deliverInternals };
29
- const execCalls = [];
30
- const audits = [];
31
- deliverInternals.authorizeTrigger = () => ({ ok: true });
32
- deliverInternals.wakeAndExecNodeMessage = async (home_id, body, tier) => {
33
- execCalls.push({ home_id, body, tier });
34
- };
35
- deliverInternals.recordTriggerAudit = ((entry) => {
36
- audits.push({ outcome: entry.outcome, trigger_id: entry.trigger_id, source_ref: entry.source_ref });
37
- return {};
38
- });
39
- try {
40
- const envelope = buildTriggerEnvelope({ source: 'webhook', trigger_id: 'r1:abc', payload: { a: 1, quote: 'has "quotes" & spaces' } });
41
- const result = await deliverTrigger({ home_id: 'h1', source_ref: 'r1', envelope, tier: 'urgent' });
42
- assert.equal(result.outcome, 'delivered');
43
- assert.equal(execCalls.length, 1);
44
- // The envelope travels as the exec STDIN body verbatim, never in argv.
45
- assert.equal(execCalls[0].body, serializeEnvelope(envelope));
46
- assert.equal(execCalls[0].tier, 'urgent');
47
- assert.equal(execCalls[0].home_id, 'h1');
48
- assert.equal(audits.length, 1);
49
- assert.equal(audits[0].outcome, 'delivered');
50
- assert.equal(audits[0].trigger_id, 'r1:abc');
51
- }
52
- finally {
53
- Object.assign(deliverInternals, orig);
54
- }
55
- });
56
- test('an unauthorized trigger never execs and records a rejected audit', async () => {
57
- const { deliverTrigger, deliverInternals } = await import('../triggers/deliver.js');
58
- const { buildTriggerEnvelope } = await import('../triggers/envelope.js');
59
- const orig = { ...deliverInternals };
60
- let execCalled = false;
61
- const audits = [];
62
- deliverInternals.authorizeTrigger = () => ({ ok: false, reason: 'cross-home targeting rejected' });
63
- deliverInternals.wakeAndExecNodeMessage = async () => {
64
- execCalled = true;
65
- };
66
- deliverInternals.recordTriggerAudit = ((entry) => {
67
- audits.push({ outcome: entry.outcome, reason: entry.reason ?? null });
68
- return {};
69
- });
70
- try {
71
- const envelope = buildTriggerEnvelope({ source: 'cron', trigger_id: 'f1', payload: {} });
72
- const result = await deliverTrigger({ home_id: 'h2', source_ref: 'f1', envelope, tier: 'urgent' });
73
- assert.equal(result.outcome, 'rejected');
74
- assert.equal(execCalled, false); // unauthorized never reaches the exec primitive
75
- assert.equal(audits.length, 1);
76
- assert.equal(audits[0].outcome, 'rejected');
77
- assert.equal(audits[0].reason, 'cross-home targeting rejected');
78
- }
79
- finally {
80
- Object.assign(deliverInternals, orig);
81
- }
82
- });
83
- test('authorizeTrigger enforces the FK-integrity guard against a real CP db', async () => {
84
- setupDb();
85
- const dbPath = process.env['CRTR_HEARTH_CP_DB_PATH'];
86
- try {
87
- const { getControlPlaneDb, closeControlPlaneDb } = await import('../db.js');
88
- const { createHome, setHomeStatus } = await import('../registry.js');
89
- const { createSchedule } = await import('../scheduler/schedule-store.js');
90
- const { claimFire } = await import('../scheduler/fire-store.js');
91
- const { authorizeTrigger } = await import('../triggers/acl.js');
92
- getControlPlaneDb(); // open + migrate
93
- createHome({ home_id: 'h1', tenant_id: 't1', user_id: 'u1', status: 'running' });
94
- createHome({ home_id: 'h2', tenant_id: 't2', user_id: 'u2', status: 'running' });
95
- const schedule = createSchedule({
96
- home_id: 'h1',
97
- label: 'x',
98
- recurrence: '*/5 * * * *',
99
- timezone: 'UTC',
100
- delivery_payload: {},
101
- next_fire_at: '2026-06-30T12:00:00.000Z',
102
- });
103
- const claim = claimFire({ schedule_id: schedule.schedule_id, scheduled_for: schedule.next_fire_at });
104
- const fireId = claim.fire.fire_id;
105
- // Matching target home → authorized.
106
- assert.deepEqual(authorizeTrigger('cron', fireId, 'h1'), { ok: true });
107
- // Cross-home targeting → rejected.
108
- assert.equal(authorizeTrigger('cron', fireId, 'h2').ok, false);
109
- // Absent source row → rejected.
110
- assert.equal(authorizeTrigger('cron', 'no-such-fire', 'h1').ok, false);
111
- // Terminated target home → rejected even though the FK matches.
112
- setHomeStatus('h1', 'terminated');
113
- assert.equal(authorizeTrigger('cron', fireId, 'h1').ok, false);
114
- closeControlPlaneDb(dbPath);
115
- }
116
- finally {
117
- teardownDb(dbPath);
118
- }
119
- });
120
- test('deliverTrigger writes real delivered/rejected audit rows through the chokepoint', async () => {
121
- setupDb();
122
- const dbPath = process.env['CRTR_HEARTH_CP_DB_PATH'];
123
- try {
124
- const { getControlPlaneDb, closeControlPlaneDb } = await import('../db.js');
125
- const { createHome } = await import('../registry.js');
126
- const { createSchedule } = await import('../scheduler/schedule-store.js');
127
- const { claimFire } = await import('../scheduler/fire-store.js');
128
- const { listTriggerAudit } = await import('../triggers/audit-store.js');
129
- const { deliverTrigger, deliverInternals } = await import('../triggers/deliver.js');
130
- const { buildTriggerEnvelope } = await import('../triggers/envelope.js');
131
- getControlPlaneDb();
132
- createHome({ home_id: 'h1', tenant_id: 't1', user_id: 'u1', status: 'running' });
133
- createHome({ home_id: 'h2', tenant_id: 't2', user_id: 'u2', status: 'running' });
134
- const schedule = createSchedule({
135
- home_id: 'h1',
136
- label: 'x',
137
- recurrence: '*/5 * * * *',
138
- timezone: 'UTC',
139
- delivery_payload: { secret: 'never-persisted-raw' },
140
- next_fire_at: '2026-06-30T12:00:00.000Z',
141
- });
142
- const fireId = claimFire({ schedule_id: schedule.schedule_id, scheduled_for: schedule.next_fire_at }).fire.fire_id;
143
- const orig = { ...deliverInternals };
144
- deliverInternals.wakeAndExecNodeMessage = async () => { }; // fake the provider exec only
145
- try {
146
- const okEnv = buildTriggerEnvelope({ source: 'cron', trigger_id: fireId, payload: { secret: 'never-persisted-raw' } });
147
- const okRes = await deliverTrigger({ home_id: 'h1', source_ref: fireId, envelope: okEnv, tier: 'urgent' });
148
- assert.equal(okRes.outcome, 'delivered');
149
- // Cross-home target → chokepoint rejects (real ACL) and never execs.
150
- const badEnv = buildTriggerEnvelope({ source: 'cron', trigger_id: fireId, payload: {} });
151
- const badRes = await deliverTrigger({ home_id: 'h2', source_ref: fireId, envelope: badEnv, tier: 'urgent' });
152
- assert.equal(badRes.outcome, 'rejected');
153
- }
154
- finally {
155
- Object.assign(deliverInternals, orig);
156
- }
157
- const rows = listTriggerAudit('h1');
158
- const outcomes = rows.map((r) => r.outcome).sort();
159
- assert.deepEqual(outcomes, ['delivered', 'rejected']);
160
- // No raw payload persisted — only a hash.
161
- for (const row of rows) {
162
- assert.equal(row.payload_sha256 !== null && row.payload_sha256.length === 64, true);
163
- assert.equal(JSON.stringify(row).includes('never-persisted-raw'), false);
164
- }
165
- closeControlPlaneDb(dbPath);
166
- }
167
- finally {
168
- teardownDb(dbPath);
169
- }
170
- });
@@ -1,230 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- // Bug-regression tests for recreate-race-fix.md §3.3/§3.5: under generation naming, a roll
4
- // or rollback recreate destroys the PRE-recreate sandbox and creates a fresh-named one.
5
- // `rollHome`/`rollbackHome` must assert the in-guest `crtr sys version` against the sandbox
6
- // name the recreate ACTUALLY reports (`refresh.providerSandboxId`) — asserting against a
7
- // `sandboxName` captured from `home.provider_sandbox_id` at entry (the pre-fix behavior)
8
- // would exec into the already-destroyed old generation instead. These tests fail if that
9
- // regresses (they pin the exec target, not just that the call succeeds).
10
- function fixtureHome(overrides = {}) {
11
- return {
12
- home_id: 'home-1',
13
- tenant_id: 't1',
14
- user_id: 'u1',
15
- provider: 'blaxel',
16
- provider_sandbox_id: 'hearth-home-1',
17
- provider_volume_id: 'vol-home-1',
18
- preview_endpoint: 'https://old.example',
19
- home_agent_target: 'node-1',
20
- relay_auth_ref: 'ref-1',
21
- status: 'running',
22
- template_version: '0.3.35',
23
- last_ready_at: null,
24
- last_wake_at: null,
25
- last_suspend_at: null,
26
- last_wake_latency_ms: null,
27
- health_verdict: 'healthy',
28
- created_at: '2026-07-01T00:00:00.000Z',
29
- updated_at: '2026-07-01T00:00:00.000Z',
30
- ...overrides,
31
- };
32
- }
33
- function fixtureTarget(overrides = {}) {
34
- return {
35
- channel: 'home',
36
- target_template_version: '0.3.36',
37
- target_image_ref: 'crouter-hearth-home-0-3-36',
38
- published_at: '2026-07-01T00:00:00.000Z',
39
- source: 'test',
40
- last_bad_target_version: null,
41
- ...overrides,
42
- };
43
- }
44
- test('a successful plain (non-roll) wake clears a stale degraded health verdict to healthy', async () => {
45
- const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
46
- const orig = { ...wakeInternals };
47
- resetWakeCacheForTests();
48
- const home = fixtureHome({ home_id: 'home-plain-wake', provider_sandbox_id: 'hearth-home-plain-wake', health_verdict: 'degraded' });
49
- let healthVerdict = null;
50
- wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
51
- // No hearth target registered, so runEnsureAwake takes the normal wake path, not the roll branch.
52
- wakeInternals.getHearthTarget = (() => null);
53
- wakeInternals.resolveSecret = (() => 'relay-token');
54
- wakeInternals.loadM0Config = (() => ({}));
55
- wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
56
- wakeInternals.probeNodeReady = (async () => true);
57
- wakeInternals.setHomeStatus = (() => home);
58
- wakeInternals.setHealthVerdict = ((_id, verdict) => {
59
- healthVerdict = verdict;
60
- return home;
61
- });
62
- wakeInternals.touchWake = (() => home);
63
- wakeInternals.touchReady = (() => home);
64
- wakeInternals.getHomeBackend = (() => ({
65
- wake: async () => ({
66
- providerName: 'blaxel',
67
- providerSandboxId: home.provider_sandbox_id,
68
- providerVolumeId: 'vol-home-1',
69
- previewEndpoint: home.preview_endpoint,
70
- route: null,
71
- homeAgentTarget: 'node-1',
72
- relayAuthRef: null,
73
- templateVersion: home.template_version,
74
- status: 'running',
75
- machine: null,
76
- updatedAt: new Date().toISOString(),
77
- }),
78
- recreateOnImage: async () => {
79
- throw new Error('recreateOnImage must not be called on the plain-wake path');
80
- },
81
- suspend: async () => {
82
- throw new Error('unused');
83
- },
84
- getMachine: async () => null,
85
- exec: async () => {
86
- throw new Error('exec must not be called on the plain-wake path');
87
- },
88
- }));
89
- try {
90
- const result = await ensureAwake(home.home_id);
91
- assert.equal(result.preview_endpoint, home.preview_endpoint);
92
- assert.equal(healthVerdict, 'healthy', 'a successful plain wake must clear a stale degraded verdict to healthy');
93
- }
94
- finally {
95
- Object.assign(wakeInternals, orig);
96
- resetWakeCacheForTests();
97
- }
98
- });
99
- test('rollHome asserts guest version against the RECREATED sandbox name, not the stale pre-roll name', async () => {
100
- const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
101
- const orig = { ...wakeInternals };
102
- resetWakeCacheForTests();
103
- const execCalls = [];
104
- const home = fixtureHome({ home_id: 'home-roll-ok', provider_sandbox_id: 'hearth-home-roll-ok' });
105
- const target = fixtureTarget();
106
- const freshName = 'hearth-home-roll-ok-gfresh1';
107
- wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
108
- wakeInternals.getHearthTarget = (() => target);
109
- wakeInternals.resolveSecret = (() => 'relay-token');
110
- wakeInternals.loadM0Config = (() => ({}));
111
- wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
112
- wakeInternals.probeNodeReady = (async () => true);
113
- wakeInternals.refreshProviderPointers = ((_id, patch) => ({ ...home, ...patch }));
114
- wakeInternals.setHomeStatus = (() => home);
115
- wakeInternals.setHealthVerdict = (() => home);
116
- wakeInternals.touchWake = (() => home);
117
- wakeInternals.touchReady = (() => home);
118
- wakeInternals.recordBadTarget = (() => null);
119
- wakeInternals.getHomeBackend = (() => ({
120
- wake: async () => {
121
- throw new Error('wake() must not be called on the roll path');
122
- },
123
- recreateOnImage: async () => ({
124
- providerName: 'blaxel',
125
- providerSandboxId: freshName,
126
- providerVolumeId: 'vol-home-1',
127
- previewEndpoint: 'https://new.example',
128
- route: null,
129
- homeAgentTarget: 'node-1',
130
- relayAuthRef: null,
131
- templateVersion: target.target_template_version,
132
- status: 'running',
133
- machine: null,
134
- updatedAt: new Date().toISOString(),
135
- }),
136
- suspend: async () => {
137
- throw new Error('unused');
138
- },
139
- getMachine: async () => null,
140
- exec: async (machineId) => {
141
- execCalls.push(machineId);
142
- return { exitCode: 0, timedOut: false, stdout: JSON.stringify({ version: target.target_template_version }), stderr: '' };
143
- },
144
- }));
145
- try {
146
- const result = await ensureAwake(home.home_id);
147
- assert.equal(result.preview_endpoint, 'https://new.example');
148
- assert.deepEqual(execCalls, [freshName], 'assertGuestVersion must exec into the RECREATED sandbox name, never the stale pre-roll name');
149
- }
150
- finally {
151
- Object.assign(wakeInternals, orig);
152
- resetWakeCacheForTests();
153
- }
154
- });
155
- test('rollbackHome asserts guest version against the RECREATED rollback sandbox name, and rollback success sets healthy with no incident', async () => {
156
- const { ensureAwake, wakeInternals, resetWakeCacheForTests } = await import('../wake.js');
157
- const orig = { ...wakeInternals };
158
- resetWakeCacheForTests();
159
- const execCalls = [];
160
- const home = fixtureHome({ home_id: 'home-roll-fail', provider_sandbox_id: 'hearth-home-roll-fail' });
161
- const target = fixtureTarget();
162
- const rollbackFreshName = 'hearth-home-roll-fail-gback1';
163
- let recreateCalls = 0;
164
- let badTargetRecorded = null;
165
- let healthVerdict = null;
166
- wakeInternals.getHomeById = ((id) => (id === home.home_id ? home : null));
167
- wakeInternals.getHearthTarget = (() => target);
168
- wakeInternals.resolveSecret = (() => 'relay-token');
169
- wakeInternals.loadM0Config = (() => ({}));
170
- wakeInternals.loadControlPlaneConfig = (() => ({ readyTimeoutMs: 1_000 }));
171
- wakeInternals.probeNodeReady = (async () => true);
172
- wakeInternals.refreshProviderPointers = ((_id, patch) => ({ ...home, ...patch }));
173
- wakeInternals.setHomeStatus = (() => home);
174
- wakeInternals.setHealthVerdict = ((_id, verdict) => {
175
- healthVerdict = verdict;
176
- return home;
177
- });
178
- wakeInternals.touchWake = (() => home);
179
- wakeInternals.touchReady = (() => home);
180
- wakeInternals.recordBadTarget = ((version) => {
181
- badTargetRecorded = version;
182
- return null;
183
- });
184
- wakeInternals.getHomeBackend = (() => ({
185
- wake: async () => {
186
- throw new Error('unused');
187
- },
188
- recreateOnImage: async (_descriptor, _imageRef, templateVersion) => {
189
- recreateCalls += 1;
190
- if (recreateCalls === 1) {
191
- // Simulate the roll-forward recreate succeeding but the guest version assert
192
- // failing downstream — rollHome's catch delegates to rollbackHome regardless of
193
- // WHERE in the try it failed.
194
- throw new Error('simulated roll failure (version mismatch)');
195
- }
196
- return {
197
- providerName: 'blaxel',
198
- providerSandboxId: rollbackFreshName,
199
- providerVolumeId: 'vol-home-1',
200
- previewEndpoint: 'https://rollback.example',
201
- route: null,
202
- homeAgentTarget: 'node-1',
203
- relayAuthRef: null,
204
- templateVersion,
205
- status: 'running',
206
- machine: null,
207
- updatedAt: new Date().toISOString(),
208
- };
209
- },
210
- suspend: async () => {
211
- throw new Error('unused');
212
- },
213
- getMachine: async () => null,
214
- exec: async (machineId) => {
215
- execCalls.push(machineId);
216
- return { exitCode: 0, timedOut: false, stdout: JSON.stringify({ version: home.template_version }), stderr: '' };
217
- },
218
- }));
219
- try {
220
- const result = await ensureAwake(home.home_id);
221
- assert.equal(result.preview_endpoint, 'https://rollback.example');
222
- assert.equal(badTargetRecorded, target.target_template_version, 'the roll failure must still poison the target (brake unconditional on roll failure)');
223
- assert.equal(healthVerdict, 'healthy', 'a successful rollback must clear health to healthy, not leave/raise an incident');
224
- assert.deepEqual(execCalls, [rollbackFreshName], "rollback must assert against ITS OWN recreated sandbox name — never the failed roll's name nor the stale pre-roll name");
225
- }
226
- finally {
227
- Object.assign(wakeInternals, orig);
228
- resetWakeCacheForTests();
229
- }
230
- });
@@ -1,167 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { createHmac } from 'node:crypto';
3
- import { test } from 'node:test';
4
- import { extractRoutingKey, processWebhookDelivery, } from '../ingress/webhook-route.js';
5
- import { serializeEnvelope } from '../triggers/envelope.js';
6
- // Category-3 test (CLAUDE.md "Testing policy" #3 — load-bearing Hearth/CP
7
- // security regression). Invariants: master plan P5.5 / part-plan T-C3 R-ING-1/
8
- // R-ING-3. (1) A webhook is delivered ONLY after HMAC-over-raw-body
9
- // verification; a bad signature never reaches route delivery or the replay
10
- // ledger. (2) Replay/idempotency: a duplicate delivery is acknowledged and NOT
11
- // re-delivered. (3) The routing key is NEVER present in the envelope the home
12
- // receives. (4) The pre-resolution rate limit sheds a flood before any route
13
- // lookup. Every one of these is a silent-if-regressed security boundary.
14
- const SECRET = 'whsec_test_value';
15
- const ROUTE = {
16
- route_id: 'r1',
17
- tenant_id: 't1',
18
- user_id: 'u1',
19
- home_id: 'h1',
20
- channel: 'webhook',
21
- routing_key: 'my-public-routing-key',
22
- routing_key_kind: 'opaque',
23
- channel_secret_ref: 'ref-r1',
24
- delivery_template: null,
25
- enabled: true,
26
- created_at: '2026-06-30T00:00:00.000Z',
27
- updated_at: '2026-06-30T00:00:00.000Z',
28
- };
29
- function sign(body) {
30
- return createHmac('sha256', SECRET).update(Buffer.from(body, 'utf8')).digest('hex');
31
- }
32
- function makeRequest(overrides = {}) {
33
- const rawBody = Buffer.from(JSON.stringify({ event: 'ping', n: 1 }), 'utf8');
34
- return {
35
- routingKey: ROUTE.routing_key,
36
- rawBody,
37
- signatureHeader: sign(rawBody.toString('utf8')),
38
- deliveryIdHeader: 'delivery-abc',
39
- contentType: 'application/json',
40
- ...overrides,
41
- };
42
- }
43
- function makeDeps(cap, overrides = {}) {
44
- return {
45
- checkRate: () => ({ ok: true }),
46
- resolveRoute: (channel, key) => {
47
- cap.routeLookups += 1;
48
- return channel === 'webhook' && key === ROUTE.routing_key ? ROUTE : null;
49
- },
50
- resolveSecret: (ref) => {
51
- if (ref !== ROUTE.channel_secret_ref)
52
- throw new Error(`unknown handle ${ref}`);
53
- return SECRET;
54
- },
55
- claimWebhookDelivery: (input) => {
56
- cap.claims += 1;
57
- return {
58
- delivery: {
59
- delivery_id: 'd1',
60
- route_id: input.route_id,
61
- tenant_id: 't1',
62
- user_id: 'u1',
63
- home_id: 'h1',
64
- replay_key: input.replay_key,
65
- replay_window_start: input.replay_window_start,
66
- payload_sha256: input.payload_sha256 ?? null,
67
- signature_sha256: input.signature_sha256 ?? null,
68
- state: 'received',
69
- received_at: input.received_at ?? '2026-06-30T12:00:00.000Z',
70
- delivered_at: null,
71
- last_error: null,
72
- created_at: '2026-06-30T12:00:00.000Z',
73
- updated_at: '2026-06-30T12:00:00.000Z',
74
- },
75
- claimed: true,
76
- };
77
- },
78
- setWebhookDeliveryState: () => { },
79
- deliverTrigger: async (input) => {
80
- cap.deliveries.push({ home_id: input.home_id, source_ref: input.source_ref, envelope: input.envelope, serialized: serializeEnvelope(input.envelope) });
81
- return { outcome: 'delivered' };
82
- },
83
- now: () => new Date('2026-06-30T12:00:00.000Z'),
84
- ...overrides,
85
- };
86
- }
87
- function makeCaptured() {
88
- return { deliveries: [], routeLookups: 0, claims: 0 };
89
- }
90
- test('a validly-signed webhook delivers once; envelope carries NO routing key', async () => {
91
- const cap = makeCaptured();
92
- const res = await processWebhookDelivery(makeRequest(), makeDeps(cap));
93
- assert.equal(res.status, 202);
94
- assert.equal(cap.deliveries.length, 1);
95
- const d = cap.deliveries[0];
96
- assert.equal(d.home_id, 'h1');
97
- assert.equal(d.source_ref, 'r1');
98
- const parsed = JSON.parse(d.serialized);
99
- // R-ING-3: exactly the four canonical fields, no routing key anywhere.
100
- assert.deepEqual(Object.keys(parsed).sort(), ['payload', 'received_at', 'source', 'trigger_id']);
101
- assert.equal(d.serialized.includes(ROUTE.routing_key), false);
102
- assert.equal(parsed['source'], 'webhook');
103
- assert.equal(parsed['trigger_id'], `r1:delivery-abc`); // stable route-derived + per-delivery
104
- assert.deepEqual(parsed['payload'], { event: 'ping', n: 1 });
105
- });
106
- test('a bad signature is rejected with no delivery and no replay-ledger write', async () => {
107
- const cap = makeCaptured();
108
- const res = await processWebhookDelivery(makeRequest({ signatureHeader: 'deadbeef' }), makeDeps(cap));
109
- assert.equal(res.status, 401);
110
- assert.equal(cap.deliveries.length, 0);
111
- assert.equal(cap.claims, 0); // no webhook_delivery row for an unverified request
112
- });
113
- test('a missing signature is rejected', async () => {
114
- const cap = makeCaptured();
115
- const res = await processWebhookDelivery(makeRequest({ signatureHeader: null }), makeDeps(cap));
116
- assert.equal(res.status, 401);
117
- assert.equal(cap.deliveries.length, 0);
118
- });
119
- test('a replayed delivery is acknowledged idempotently and not re-delivered', async () => {
120
- const cap = makeCaptured();
121
- const deps = makeDeps(cap, {
122
- claimWebhookDelivery: (input) => ({
123
- delivery: {
124
- delivery_id: 'd1',
125
- route_id: input.route_id,
126
- tenant_id: 't1',
127
- user_id: 'u1',
128
- home_id: 'h1',
129
- replay_key: input.replay_key,
130
- replay_window_start: input.replay_window_start,
131
- payload_sha256: null,
132
- signature_sha256: null,
133
- state: 'delivered',
134
- received_at: '2026-06-30T11:00:00.000Z',
135
- delivered_at: '2026-06-30T11:00:00.000Z',
136
- last_error: null,
137
- created_at: '2026-06-30T11:00:00.000Z',
138
- updated_at: '2026-06-30T11:00:00.000Z',
139
- },
140
- claimed: false, // slot already held → replay
141
- }),
142
- });
143
- const res = await processWebhookDelivery(makeRequest(), deps);
144
- assert.equal(res.status, 200);
145
- assert.equal(cap.deliveries.length, 0);
146
- });
147
- test('an unknown routing key is a 404 with no delivery', async () => {
148
- const cap = makeCaptured();
149
- const res = await processWebhookDelivery(makeRequest({ routingKey: 'nope' }), makeDeps(cap));
150
- assert.equal(res.status, 404);
151
- assert.equal(cap.deliveries.length, 0);
152
- });
153
- test('the pre-resolution rate limit sheds before any route lookup', async () => {
154
- const cap = makeCaptured();
155
- const deps = makeDeps(cap, { checkRate: () => ({ ok: false, retryAfterMs: 1_000 }) });
156
- const res = await processWebhookDelivery(makeRequest(), deps);
157
- assert.equal(res.status, 429);
158
- assert.equal(cap.routeLookups, 0); // rate limit is evaluated before resolveRoute
159
- assert.equal(cap.deliveries.length, 0);
160
- });
161
- test('extractRoutingKey rejects traversal / multi-segment / empty keys', () => {
162
- assert.equal(extractRoutingKey('/webhook/abc'), 'abc');
163
- assert.equal(extractRoutingKey('/webhook/a%2Fb'), null); // decoded embedded slash
164
- assert.equal(extractRoutingKey('/webhook/a/b'), null);
165
- assert.equal(extractRoutingKey('/webhook/'), null);
166
- assert.equal(extractRoutingKey('/webhook'), null);
167
- });
@@ -1,21 +0,0 @@
1
- export interface ControlPlaneConfig {
2
- host: string;
3
- port: number;
4
- publicOrigin: string;
5
- /** Whether the Hearth session cookie should carry `Secure` — true whenever
6
- * `publicOrigin` is `https://`, mirroring the wake-proxy's rule. */
7
- cookieSecure: boolean;
8
- /** Upper bound P4.2's wake orchestration (`ensureAwake`) uses for a
9
- * resume/recreate cycle before giving up. Not read by this module or by
10
- * server.ts in this phase — carried here so the CP has exactly one config
11
- * object once wake.ts lands. */
12
- wakeTimeoutMs: number;
13
- /** Ready-probe retry window P4.2's wake orchestration uses against
14
- * `/node/<target>`. Not read by this module or by server.ts in this
15
- * phase. */
16
- readyTimeoutMs: number;
17
- }
18
- /** Load the CP server config from the environment (defaults applied for
19
- * dogfood: loopback bind, port 8080). Throws on an invalid/missing value
20
- * rather than silently falling back to an insecure default. */
21
- export declare function loadControlPlaneConfig(env?: NodeJS.ProcessEnv): ControlPlaneConfig;
@@ -1,77 +0,0 @@
1
- // control-plane/config.ts — the CP server's own config: bind address, public
2
- // origin (used by session.ts for cookie security and state-changing
3
- // browser-origin checks), and the wake/ready timeout knobs P4.2's wake
4
- // orchestration will read off this same config object. Deliberately does
5
- // NOT duplicate CP data-path resolution: the CP db path
6
- // (`CRTR_HEARTH_CP_DATA_DIR`/`CRTR_HEARTH_CP_DB_PATH`, db.ts) and the
7
- // secrets-file path (`CRTR_HEARTH_CP_SECRETS_FILE`, secrets.ts) are each
8
- // resolved by their own module — callers go to those modules directly
9
- // rather than threading a second, possibly-divergent copy through here.
10
- import { general } from '../../core/errors.js';
11
- const ENV = {
12
- host: 'CRTR_HEARTH_CP_HOST',
13
- port: 'CRTR_HEARTH_CP_PORT',
14
- publicOrigin: 'CRTR_HEARTH_CP_PUBLIC_ORIGIN',
15
- wakeTimeoutMs: 'CRTR_HEARTH_CP_WAKE_TIMEOUT_MS',
16
- readyTimeoutMs: 'CRTR_HEARTH_CP_READY_TIMEOUT_MS',
17
- };
18
- function parsePort(value, fallback) {
19
- if (value === undefined || value.trim() === '')
20
- return fallback;
21
- const parsed = Number(value);
22
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
23
- throw general(`invalid ${ENV.port}: ${value}`, { env: ENV.port });
24
- }
25
- return parsed;
26
- }
27
- function parseDuration(value, fallback, name) {
28
- if (value === undefined || value.trim() === '')
29
- return fallback;
30
- const parsed = Number(value);
31
- if (!Number.isInteger(parsed) || parsed < 0) {
32
- throw general(`invalid ${name}: ${value}`, { env: name });
33
- }
34
- return parsed;
35
- }
36
- function isLoopbackHostname(hostname) {
37
- return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '::ffff:127.0.0.1' || hostname.startsWith('127.');
38
- }
39
- function parsePublicOrigin(env, host, port) {
40
- const raw = env[ENV.publicOrigin]?.trim() ?? '';
41
- if (raw !== '') {
42
- let parsed;
43
- try {
44
- parsed = new URL(raw);
45
- }
46
- catch {
47
- throw general(`invalid ${ENV.publicOrigin}: must be a valid URL`, { env: ENV.publicOrigin });
48
- }
49
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
50
- throw general(`invalid ${ENV.publicOrigin}: scheme must be http: or https:`, { env: ENV.publicOrigin, scheme: parsed.protocol });
51
- }
52
- const origin = parsed.origin;
53
- if (origin === 'null' || origin === '') {
54
- throw general(`invalid ${ENV.publicOrigin}: must be a valid web origin`, { env: ENV.publicOrigin });
55
- }
56
- if (parsed.protocol !== 'https:' && !isLoopbackHostname(parsed.hostname)) {
57
- throw general(`${ENV.publicOrigin} must use https: for a non-loopback origin`, { env: ENV.publicOrigin, hostname: parsed.hostname });
58
- }
59
- return { publicOrigin: origin, cookieSecure: parsed.protocol === 'https:' };
60
- }
61
- if (isLoopbackHostname(host)) {
62
- const origin = `http://127.0.0.1:${port}`;
63
- return { publicOrigin: origin, cookieSecure: false };
64
- }
65
- throw general(`${ENV.publicOrigin} is required when ${ENV.host} is bound off-loopback`, { env: ENV.publicOrigin, host });
66
- }
67
- /** Load the CP server config from the environment (defaults applied for
68
- * dogfood: loopback bind, port 8080). Throws on an invalid/missing value
69
- * rather than silently falling back to an insecure default. */
70
- export function loadControlPlaneConfig(env = process.env) {
71
- const host = (env[ENV.host] ?? '127.0.0.1').trim() || '127.0.0.1';
72
- const port = parsePort(env[ENV.port], 8080);
73
- const { publicOrigin, cookieSecure } = parsePublicOrigin(env, host, port);
74
- const wakeTimeoutMs = parseDuration(env[ENV.wakeTimeoutMs], 150_000, ENV.wakeTimeoutMs);
75
- const readyTimeoutMs = parseDuration(env[ENV.readyTimeoutMs], 8_000, ENV.readyTimeoutMs);
76
- return { host, port, publicOrigin, cookieSecure, wakeTimeoutMs, readyTimeoutMs };
77
- }