@north-light/api 0.0.0-stage → 0.3.414

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 (247) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +66 -2
  3. package/dist/api/__tests__/error-codes.test.d.ts +1 -0
  4. package/dist/api/__tests__/error-codes.test.js +79 -0
  5. package/dist/api/__tests__/integration/client.test.d.ts +1 -0
  6. package/dist/api/__tests__/integration/client.test.js +276 -0
  7. package/dist/api/client.d.ts +501 -0
  8. package/dist/api/client.js +1281 -0
  9. package/dist/api/command-manifest/index.d.ts +3 -0
  10. package/dist/api/command-manifest/index.js +3 -0
  11. package/dist/api/command-manifest/manifest.d.ts +51 -0
  12. package/dist/api/command-manifest/manifest.js +332 -0
  13. package/dist/api/command-manifest/result.d.ts +25 -0
  14. package/dist/api/command-manifest/result.js +97 -0
  15. package/dist/api/command-manifest/schema.d.ts +28 -0
  16. package/dist/api/command-manifest/schema.js +856 -0
  17. package/dist/api/dto/analytics.d.ts +184 -0
  18. package/dist/api/dto/analytics.js +3 -0
  19. package/dist/api/dto/attach.d.ts +22 -0
  20. package/dist/api/dto/attach.js +13 -0
  21. package/dist/api/dto/bash-jobs.d.ts +24 -0
  22. package/dist/api/dto/bash-jobs.js +9 -0
  23. package/dist/api/dto/bash.d.ts +17 -0
  24. package/dist/api/dto/bash.js +1 -0
  25. package/dist/api/dto/broker-ops.d.ts +187 -0
  26. package/dist/api/dto/broker-ops.js +6 -0
  27. package/dist/api/dto/broker-signals.d.ts +25 -0
  28. package/dist/api/dto/broker-signals.js +1 -0
  29. package/dist/api/dto/broker.d.ts +86 -0
  30. package/dist/api/dto/broker.js +20 -0
  31. package/dist/api/dto/canvas.d.ts +359 -0
  32. package/dist/api/dto/canvas.js +2 -0
  33. package/dist/api/dto/chat-inventory.d.ts +56 -0
  34. package/dist/api/dto/chat-inventory.js +11 -0
  35. package/dist/api/dto/common.d.ts +29 -0
  36. package/dist/api/dto/common.js +15 -0
  37. package/dist/api/dto/config.d.ts +46 -0
  38. package/dist/api/dto/config.js +3 -0
  39. package/dist/api/dto/crons.d.ts +158 -0
  40. package/dist/api/dto/crons.js +10 -0
  41. package/dist/api/dto/custom-objects.d.ts +66 -0
  42. package/dist/api/dto/custom-objects.js +1 -0
  43. package/dist/api/dto/delivery.d.ts +71 -0
  44. package/dist/api/dto/delivery.js +7 -0
  45. package/dist/api/dto/docs.d.ts +135 -0
  46. package/dist/api/dto/docs.js +8 -0
  47. package/dist/api/dto/files.d.ts +21 -0
  48. package/dist/api/dto/files.js +1 -0
  49. package/dist/api/dto/focus.d.ts +24 -0
  50. package/dist/api/dto/focus.js +10 -0
  51. package/dist/api/dto/grants.d.ts +28 -0
  52. package/dist/api/dto/grants.js +1 -0
  53. package/dist/api/dto/health.d.ts +116 -0
  54. package/dist/api/dto/health.js +2 -0
  55. package/dist/api/dto/human-requests.d.ts +113 -0
  56. package/dist/api/dto/human-requests.js +4 -0
  57. package/dist/api/dto/human.d.ts +28 -0
  58. package/dist/api/dto/human.js +4 -0
  59. package/dist/api/dto/inbox.d.ts +273 -0
  60. package/dist/api/dto/inbox.js +4 -0
  61. package/dist/api/dto/lifecycle.d.ts +88 -0
  62. package/dist/api/dto/lifecycle.js +3 -0
  63. package/dist/api/dto/limits.d.ts +29 -0
  64. package/dist/api/dto/limits.js +2 -0
  65. package/dist/api/dto/mail.d.ts +44 -0
  66. package/dist/api/dto/mail.js +1 -0
  67. package/dist/api/dto/messages.d.ts +104 -0
  68. package/dist/api/dto/messages.js +2 -0
  69. package/dist/api/dto/model-config.d.ts +25 -0
  70. package/dist/api/dto/model-config.js +1 -0
  71. package/dist/api/dto/modelauth.d.ts +132 -0
  72. package/dist/api/dto/modelauth.js +4 -0
  73. package/dist/api/dto/node-events.d.ts +65 -0
  74. package/dist/api/dto/node-events.js +4 -0
  75. package/dist/api/dto/node-outcomes.d.ts +88 -0
  76. package/dist/api/dto/node-outcomes.js +2 -0
  77. package/dist/api/dto/node-records.d.ts +35 -0
  78. package/dist/api/dto/node-records.js +5 -0
  79. package/dist/api/dto/nodes.d.ts +371 -0
  80. package/dist/api/dto/nodes.js +3 -0
  81. package/dist/api/dto/objects.d.ts +172 -0
  82. package/dist/api/dto/objects.js +5 -0
  83. package/dist/api/dto/profiles.d.ts +117 -0
  84. package/dist/api/dto/profiles.js +4 -0
  85. package/dist/api/dto/recovery.d.ts +104 -0
  86. package/dist/api/dto/recovery.js +1 -0
  87. package/dist/api/dto/reports.d.ts +95 -0
  88. package/dist/api/dto/reports.js +2 -0
  89. package/dist/api/dto/review-comments.d.ts +146 -0
  90. package/dist/api/dto/review-comments.js +5 -0
  91. package/dist/api/dto/reviews.d.ts +113 -0
  92. package/dist/api/dto/reviews.js +5 -0
  93. package/dist/api/dto/run-events.d.ts +295 -0
  94. package/dist/api/dto/run-events.js +6 -0
  95. package/dist/api/dto/structured-requests.d.ts +64 -0
  96. package/dist/api/dto/structured-requests.js +5 -0
  97. package/dist/api/dto/subscriptions.d.ts +14 -0
  98. package/dist/api/dto/subscriptions.js +2 -0
  99. package/dist/api/dto/worktree.d.ts +55 -0
  100. package/dist/api/dto/worktree.js +6 -0
  101. package/dist/api/error-codes.d.ts +259 -0
  102. package/dist/api/error-codes.js +56 -0
  103. package/dist/api/errors.d.ts +47 -0
  104. package/dist/api/errors.js +66 -0
  105. package/dist/api/index.d.ts +48 -0
  106. package/dist/api/index.js +45 -0
  107. package/dist/api/long-poll.d.ts +59 -0
  108. package/dist/api/long-poll.js +67 -0
  109. package/dist/api/node-transport.d.ts +18 -0
  110. package/dist/api/node-transport.js +105 -0
  111. package/dist/api/plugin-manifest-schema.d.ts +233 -0
  112. package/dist/api/plugin-manifest-schema.js +23 -0
  113. package/dist/api/routes.d.ts +164 -0
  114. package/dist/api/routes.js +197 -0
  115. package/dist/api/runtime-version.d.ts +31 -0
  116. package/dist/api/runtime-version.js +50 -0
  117. package/dist/core/asset-root.d.ts +7 -0
  118. package/dist/core/asset-root.js +18 -0
  119. package/dist/core/canvas/boot-id.d.ts +6 -0
  120. package/dist/core/canvas/boot-id.js +26 -0
  121. package/dist/core/canvas/paths.d.ts +72 -0
  122. package/dist/core/canvas/paths.js +163 -0
  123. package/dist/core/canvas/pid.d.ts +391 -0
  124. package/dist/core/canvas/pid.js +948 -0
  125. package/dist/core/command-plugins/bundle.d.ts +149 -0
  126. package/dist/core/command-plugins/bundle.js +588 -0
  127. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  128. package/dist/core/command-plugins/endpoint.js +51 -0
  129. package/dist/core/config.d.ts +233 -0
  130. package/dist/core/config.js +1120 -0
  131. package/dist/core/env-name.d.ts +6 -0
  132. package/dist/core/env-name.js +9 -0
  133. package/dist/core/errors.d.ts +38 -0
  134. package/dist/core/errors.js +90 -0
  135. package/dist/core/events/emit.d.ts +6 -0
  136. package/dist/core/events/emit.js +42 -0
  137. package/dist/core/events/envelope.d.ts +2 -0
  138. package/dist/core/events/envelope.js +84 -0
  139. package/dist/core/events/errors.d.ts +4 -0
  140. package/dist/core/events/errors.js +69 -0
  141. package/dist/core/events/operation-id.d.ts +4 -0
  142. package/dist/core/events/operation-id.js +24 -0
  143. package/dist/core/events/serialize.d.ts +4 -0
  144. package/dist/core/events/serialize.js +199 -0
  145. package/dist/core/events/source.d.ts +16 -0
  146. package/dist/core/events/source.js +31 -0
  147. package/dist/core/events/types.d.ts +68 -0
  148. package/dist/core/events/types.js +11 -0
  149. package/dist/core/exclusive-lock.d.ts +34 -0
  150. package/dist/core/exclusive-lock.js +197 -0
  151. package/dist/core/fs-utils.d.ts +44 -0
  152. package/dist/core/fs-utils.js +208 -0
  153. package/dist/core/help.d.ts +309 -0
  154. package/dist/core/help.js +406 -0
  155. package/dist/core/human/page-catalog.d.ts +57 -0
  156. package/dist/core/human/page-catalog.js +172 -0
  157. package/dist/core/installed-plugins.d.ts +2 -0
  158. package/dist/core/installed-plugins.js +79 -0
  159. package/dist/core/io.d.ts +122 -0
  160. package/dist/core/io.js +373 -0
  161. package/dist/core/keybindings/attach-control.d.ts +49 -0
  162. package/dist/core/keybindings/attach-control.js +42 -0
  163. package/dist/core/keybindings/catalog.d.ts +18 -0
  164. package/dist/core/keybindings/catalog.js +257 -0
  165. package/dist/core/keybindings/types.d.ts +42 -0
  166. package/dist/core/keybindings/types.js +1 -0
  167. package/dist/core/layout.d.ts +34 -0
  168. package/dist/core/layout.js +106 -0
  169. package/dist/core/locked-file.d.ts +27 -0
  170. package/dist/core/locked-file.js +118 -0
  171. package/dist/core/log.d.ts +9 -0
  172. package/dist/core/log.js +89 -0
  173. package/dist/core/manifest.d.ts +5 -0
  174. package/dist/core/manifest.js +15 -0
  175. package/dist/core/plugin-env.d.ts +8 -0
  176. package/dist/core/plugin-env.js +31 -0
  177. package/dist/core/plugin-extensions.d.ts +29 -0
  178. package/dist/core/plugin-extensions.js +191 -0
  179. package/dist/core/plugin-swap-lock.d.ts +9 -0
  180. package/dist/core/plugin-swap-lock.js +31 -0
  181. package/dist/core/preview-result-path.d.ts +4 -0
  182. package/dist/core/preview-result-path.js +26 -0
  183. package/dist/core/profiles/env-store.d.ts +22 -0
  184. package/dist/core/profiles/env-store.js +163 -0
  185. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  186. package/dist/core/profiles/fuzzy-match.js +92 -0
  187. package/dist/core/profiles/manifest.d.ts +120 -0
  188. package/dist/core/profiles/manifest.js +529 -0
  189. package/dist/core/rate-limit-scope.d.ts +25 -0
  190. package/dist/core/rate-limit-scope.js +64 -0
  191. package/dist/core/render.d.ts +12 -0
  192. package/dist/core/render.js +138 -0
  193. package/dist/core/resolver.d.ts +14 -0
  194. package/dist/core/resolver.js +111 -0
  195. package/dist/core/runtime/branded-host.d.ts +55 -0
  196. package/dist/core/runtime/branded-host.js +485 -0
  197. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  198. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  199. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  200. package/dist/core/runtime/broker/signal-stream.js +149 -0
  201. package/dist/core/scope.d.ts +32 -0
  202. package/dist/core/scope.js +184 -0
  203. package/dist/core/scoped-state/db.d.ts +17 -0
  204. package/dist/core/scoped-state/db.js +247 -0
  205. package/dist/core/scoped-state/migrate.d.ts +8 -0
  206. package/dist/core/scoped-state/migrate.js +187 -0
  207. package/dist/core/scoped-state/paths.d.ts +9 -0
  208. package/dist/core/scoped-state/paths.js +27 -0
  209. package/dist/core/scoped-state/profiles.d.ts +27 -0
  210. package/dist/core/scoped-state/profiles.js +93 -0
  211. package/dist/core/scoped-state/providers.d.ts +24 -0
  212. package/dist/core/scoped-state/providers.js +19 -0
  213. package/dist/core/scoped-state/schema.d.ts +6 -0
  214. package/dist/core/scoped-state/schema.js +43 -0
  215. package/dist/core/scoped-state/settings.d.ts +28 -0
  216. package/dist/core/scoped-state/settings.js +83 -0
  217. package/dist/core/spaces/open-beneath.d.ts +71 -0
  218. package/dist/core/spaces/open-beneath.js +581 -0
  219. package/dist/core/sqlite-statements.d.ts +4 -0
  220. package/dist/core/sqlite-statements.js +17 -0
  221. package/dist/core/subscription-state.d.ts +121 -0
  222. package/dist/core/subscription-state.js +287 -0
  223. package/dist/core/user-settings.d.ts +377 -0
  224. package/dist/core/user-settings.js +458 -0
  225. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  226. package/dist/daemon/broker-signals/bus.js +87 -0
  227. package/dist/daemon/manage.d.ts +176 -0
  228. package/dist/daemon/manage.js +664 -0
  229. package/dist/daemon/pidfile.d.ts +8 -0
  230. package/dist/daemon/pidfile.js +37 -0
  231. package/dist/daemon/startup-policy.d.ts +1 -0
  232. package/dist/daemon/startup-policy.js +1 -0
  233. package/dist/native/linux.d.ts +29 -0
  234. package/dist/native/linux.js +20 -0
  235. package/dist/shared/env.d.ts +116 -0
  236. package/dist/shared/env.js +271 -0
  237. package/dist/shared/generated-context.d.ts +83 -0
  238. package/dist/shared/generated-context.js +235 -0
  239. package/dist/shared/inbox-entry-body.d.ts +22 -0
  240. package/dist/shared/inbox-entry-body.js +116 -0
  241. package/dist/shared/predicates.d.ts +2 -0
  242. package/dist/shared/predicates.js +4 -0
  243. package/dist/shared/working-activity.d.ts +9 -0
  244. package/dist/shared/working-activity.js +27 -0
  245. package/dist/types.d.ts +562 -0
  246. package/dist/types.js +186 -0
  247. package/package.json +47 -4
@@ -0,0 +1,485 @@
1
+ // branded-host.ts — a crouter-branded copy of the `node` binary, so the
2
+ // long-lived engine processes (brokers + the crtrd daemon) show "crouter" in
3
+ // macOS' TCC / Full Disk Access list instead of the generic "node".
4
+ //
5
+ // WHY a real copied binary (not a symlink, not process.title): macOS keys a
6
+ // Full Disk Access entry on the Mach-O binary's *filename* — not argv[0], not
7
+ // process.title, and symlinks resolve to their realpath. So the ONLY way to show
8
+ // a branded name is to launch the engine from a real Mach-O file whose filename
9
+ // is the brand. We copy the node launcher to `~/.crouter/host/crouter`.
10
+ //
11
+ // WHY self-contained (libnode beside it): a Homebrew node is a small (~70 KB)
12
+ // launcher that dynamically loads `@rpath/libnode.<abi>.dylib` (~70 MB). node's
13
+ // LC_RPATH includes `@loader_path`, so a copy of that dylib placed *beside* the
14
+ // branded binary resolves with no DYLD_* env var (which SIP can strip) and no
15
+ // dependency on the Cellar path surviving — even after a `brew uninstall` of the
16
+ // old keg, the branded copy keeps running. A statically-linked node (nodejs.org)
17
+ // has no separate libnode; we just copy the binary. The remaining absolute
18
+ // `/opt/homebrew/opt/...` deps resolve by their baked-in absolute paths.
19
+ //
20
+ // SELF-HEAL: ensureBrandedHost() rebuilds the copy whenever the live node drifts
21
+ // (a `brew upgrade node` moves realpath(execPath) to a new Cellar version), the
22
+ // copy is missing, or the copy no longer starts. The last case matters because
23
+ // the copied libnode does NOT protect the other absolute Homebrew deps (e.g.
24
+ // libsimdjson.<n>.dylib): a `brew upgrade` that bumps one leaves the branded host
25
+ // aborting in dyld while its marker still matches. That holds even when the
26
+ // caller is itself running as the branded host (crtrd, a broker): it then
27
+ // rebuilds from a real node found via findRealNode(), never from itself.
28
+ //
29
+ // FALLBACK: inside the host, childNodeExecPath() / hostExecPath() use a real
30
+ // node (same native-module ABI) while the host cannot start, probing
31
+ // asynchronously and rebuilding the host in a detached child — the daemon's
32
+ // event loop never blocks on it.
33
+ import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, rmSync, linkSync, } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { dirname, join } from 'node:path';
36
+ import { execFile, spawn, spawnSync } from 'node:child_process';
37
+ import { CRTR_DIR_NAME } from '../../types.js';
38
+ import { emitEvent } from '../events/emit.js';
39
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
40
+ /** The filename the FDA / TCC entry is shown under. */
41
+ const BRANDED_BIN_NAME = 'crouter';
42
+ /** Machine-global host dir in this process owner’s HOME. Only the daemon reconciles it; agents do not install the branded host. Deliberately NOT under `CRTR_HOME` (tests override
43
+ * that to an isolated temp): the branded binary is a per-machine artifact keyed
44
+ * to the installed node, shared across every canvas home. */
45
+ export function brandedHostDir() {
46
+ return join(homedir(), CRTR_DIR_NAME, 'host');
47
+ }
48
+ export function brandedHostBin() {
49
+ return join(brandedHostDir(), BRANDED_BIN_NAME);
50
+ }
51
+ function markerPath() {
52
+ return join(brandedHostDir(), '.source.json');
53
+ }
54
+ /** Locate the `libnode.<abi>.dylib` a Homebrew node loads via `@rpath`. node's
55
+ * rpaths are `@loader_path` and `@loader_path/../lib`, so check the binary's own
56
+ * dir first, then the sibling `lib/`. Returns the absolute path, or null for a
57
+ * statically-linked node that has no separate libnode. */
58
+ function findLibnode(nodeBin) {
59
+ const binDir = dirname(nodeBin);
60
+ for (const dir of [binDir, join(binDir, '..', 'lib')]) {
61
+ if (!existsSync(dir))
62
+ continue;
63
+ const hit = readdirSync(dir).find((f) => f.startsWith('libnode.') && f.endsWith('.dylib'));
64
+ if (hit !== undefined)
65
+ return join(dir, hit);
66
+ }
67
+ return null;
68
+ }
69
+ /** Copy `src` → `dst` via a unique temp + atomic rename, so a failed copy
70
+ * never leaves a half-written artifact. Host-wide concurrency is serialized by
71
+ * the dedicated reconcile lock below. */
72
+ function atomicCopy(src, dst, mode) {
73
+ const tmp = `${dst}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
74
+ copyFileSync(src, tmp);
75
+ if (mode !== undefined)
76
+ chmodSync(tmp, mode);
77
+ renameSync(tmp, dst);
78
+ }
79
+ function readMarker(path = markerPath()) {
80
+ try {
81
+ return JSON.parse(readFileSync(path, 'utf8'));
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ const HOST_LOCK_TIMEOUT_MS = 30_000;
88
+ /** Serialize reconciliation of the one machine-global host tuple. This lock is
89
+ * deliberately separate from the immutable runtime-generation install lock. */
90
+ function withBrandedHostLock(run) {
91
+ const root = join(homedir(), CRTR_DIR_NAME);
92
+ const lockPath = join(root, 'host.lock');
93
+ mkdirSync(root, { recursive: true });
94
+ return withExclusiveDirectoryLock(lockPath, run, {
95
+ timeoutMs: HOST_LOCK_TIMEOUT_MS,
96
+ timeoutError: () => new Error(`timed out waiting for branded host lock: ${lockPath}`),
97
+ });
98
+ }
99
+ /** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
100
+ * keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
101
+ * portable default for machines without a local signing certificate. */
102
+ export function codesignIdentitiesForBrandedHost(opts = {}) {
103
+ const configured = opts.configured?.trim();
104
+ if (configured)
105
+ return [configured];
106
+ const discovered = opts.discovered?.trim();
107
+ return [discovered || '-'];
108
+ }
109
+ function discoverCodesignIdentity() {
110
+ if (process.platform !== 'darwin')
111
+ return null;
112
+ const res = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { stdio: ['pipe', 'pipe', 'pipe'] });
113
+ if (res.status !== 0)
114
+ return null;
115
+ const stdout = (res.stdout ?? '').toString();
116
+ const identities = stdout
117
+ .split('\n')
118
+ .map((line) => line.match(/\)\s+[A-Fa-f0-9]{40}\s+"([^"]+)"/)?.[1] ?? null)
119
+ .filter((identity) => identity !== null);
120
+ return identities.find((identity) => identity.startsWith('Apple Development:'))
121
+ ?? identities.find((identity) => identity.startsWith('Developer ID Application:'))
122
+ ?? identities[0]
123
+ ?? null;
124
+ }
125
+ function runCodesign(binPath, identity) {
126
+ const res = spawnSync('codesign', ['-s', identity, '--deep', '--force', binPath], { stdio: ['pipe', 'pipe', 'pipe'] });
127
+ return { ok: res.status === 0, stderr: (res.stderr ?? '').toString().trim() };
128
+ }
129
+ function signBrandedHost(binPath) {
130
+ if (process.platform !== 'darwin')
131
+ return;
132
+ const identities = codesignIdentitiesForBrandedHost({
133
+ configured: process.env['CRTR_CODESIGN_IDENTITY'],
134
+ discovered: discoverCodesignIdentity(),
135
+ });
136
+ let lastError = '';
137
+ for (const identity of identities) {
138
+ const res = runCodesign(binPath, identity);
139
+ if (res.ok)
140
+ return;
141
+ lastError = res.stderr;
142
+ }
143
+ if (lastError && !lastError.includes('valid on disk')) {
144
+ emitEvent({ event: 'host.codesign.failed', level: 'debug', error: lastError });
145
+ }
146
+ throw new Error(`codesign failed for ${binPath}: ${lastError || 'codesign did not exit successfully'}`);
147
+ }
148
+ export function signBrandedHostArtifacts(binPath, libnodePath, sign = signBrandedHost) {
149
+ if (libnodePath !== null)
150
+ sign(libnodePath);
151
+ sign(binPath);
152
+ }
153
+ export function brandedHostWorks(binPath, timeoutMs = 5_000) {
154
+ try {
155
+ const res = spawnSync(binPath, ['-e', ''], {
156
+ stdio: 'ignore',
157
+ timeout: timeoutMs,
158
+ });
159
+ return res.status === 0;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
165
+ /** Ensure the branded host binary exists and matches the live node; return its
166
+ * absolute path. Idempotent and self-healing. Throws on a genuine build failure
167
+ * (disk full / permissions); hostExecPath() turns that into a logged fallback to
168
+ * a real node so a broken host never blocks launching. */
169
+ export function ensureBrandedHost(opts = {}) {
170
+ return withBrandedHostLock(() => {
171
+ const hostDir = brandedHostDir();
172
+ const dst = brandedHostBin();
173
+ let src = realpathSync(opts.execPath ?? process.execPath);
174
+ // If THIS process is running as the branded host (the daemon or a broker),
175
+ // the live process proves only that the copy *was* loadable. It says nothing
176
+ // about whether a fresh process can start it: a moved Homebrew dylib breaks
177
+ // every new exec while the running one carries on. So re-check, and when the
178
+ // copy is broken rebuild from a real node — we can't copy ourselves.
179
+ if (existsSync(dst) && realpathSync(dst) === src) {
180
+ if (brandedHostWorks(dst))
181
+ return dst;
182
+ const real = findRealNode();
183
+ if (real === null) {
184
+ throw new Error(`branded host cannot start and no working node was found to rebuild it from: ${dst}`);
185
+ }
186
+ src = real;
187
+ }
188
+ const marker = readMarker();
189
+ const libnodeSrc = findLibnode(src);
190
+ const libnodeName = libnodeSrc !== null ? libnodeSrc.split('/').pop() : null;
191
+ const tupleExists = (candidate) => existsSync(dst) &&
192
+ candidate !== null &&
193
+ candidate.version === 2 &&
194
+ (candidate.libnode === null || existsSync(join(hostDir, candidate.libnode)));
195
+ const tupleMatches = (candidate) => tupleExists(candidate) && candidate.source === src && candidate.libnode === libnodeName;
196
+ if (tupleMatches(marker) && brandedHostWorks(dst))
197
+ return dst;
198
+ // Build and validate a complete tuple privately. Failures before publication
199
+ // leave any previous valid host untouched.
200
+ mkdirSync(hostDir, { recursive: true });
201
+ const stageDir = join(hostDir, `.reconcile.${process.pid}.${Math.random().toString(36).slice(2)}`);
202
+ const backupDir = join(stageDir, 'previous');
203
+ mkdirSync(stageDir);
204
+ let publicationStarted = false;
205
+ const previousTupleIsValid = tupleExists(marker) && brandedHostWorks(dst);
206
+ try {
207
+ const stageBin = join(stageDir, BRANDED_BIN_NAME);
208
+ const stageLibnode = libnodeName !== null ? join(stageDir, libnodeName) : null;
209
+ if (libnodeSrc !== null && stageLibnode !== null)
210
+ atomicCopy(libnodeSrc, stageLibnode);
211
+ atomicCopy(src, stageBin, 0o755);
212
+ signBrandedHostArtifacts(stageBin, stageLibnode);
213
+ if (!brandedHostWorks(stageBin)) {
214
+ throw new Error(`rebuilt branded host failed its startup check: ${stageBin}`);
215
+ }
216
+ if (previousTupleIsValid)
217
+ backupHostTuple(backupDir, dst, marker);
218
+ publicationStarted = true;
219
+ if (stageLibnode !== null)
220
+ renameSync(stageLibnode, join(hostDir, libnodeName));
221
+ // Both artifacts were validated together in the stage dir. A libnode keeps
222
+ // its filename only when its ABI is unchanged (the name embeds it), so a
223
+ // launcher that starts between these two renames pairs the old stub with a
224
+ // same-ABI libnode, which loads; a changed ABI gets a new name and never
225
+ // replaces the old file in place.
226
+ renameSync(stageBin, dst);
227
+ atomicCopyMarker({ version: 2, source: src, libnode: libnodeName });
228
+ if (!tupleMatches(readMarker()) || !brandedHostWorks(dst)) {
229
+ throw new Error(`published branded host tuple failed validation: ${dst}`);
230
+ }
231
+ // Cleanup follows successful marker-last publication and validation.
232
+ if (marker?.libnode != null && marker.libnode !== libnodeName) {
233
+ try {
234
+ unlinkSync(join(hostDir, marker.libnode));
235
+ }
236
+ catch {
237
+ /* best-effort */
238
+ }
239
+ }
240
+ return dst;
241
+ }
242
+ catch (error) {
243
+ if (publicationStarted && previousTupleIsValid)
244
+ restoreHostTuple(backupDir, dst, marker);
245
+ throw error;
246
+ }
247
+ finally {
248
+ rmSync(stageDir, { recursive: true, force: true });
249
+ }
250
+ });
251
+ }
252
+ function atomicCopyMarker(marker) {
253
+ const path = markerPath();
254
+ const tmp = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
255
+ writeFileSync(tmp, JSON.stringify(marker));
256
+ renameSync(tmp, path);
257
+ }
258
+ function backupHostTuple(backupDir, binPath, marker) {
259
+ mkdirSync(backupDir);
260
+ linkSync(binPath, join(backupDir, BRANDED_BIN_NAME));
261
+ if (marker.libnode !== null)
262
+ linkSync(join(brandedHostDir(), marker.libnode), join(backupDir, marker.libnode));
263
+ writeFileSync(join(backupDir, '.source.json'), JSON.stringify(marker));
264
+ }
265
+ function restoreHostTuple(backupDir, binPath, marker) {
266
+ if (marker.libnode !== null)
267
+ renameSync(join(backupDir, marker.libnode), join(brandedHostDir(), marker.libnode));
268
+ renameSync(join(backupDir, BRANDED_BIN_NAME), binPath);
269
+ renameSync(join(backupDir, '.source.json'), markerPath());
270
+ if (!brandedHostWorks(binPath))
271
+ throw new Error(`failed to restore previous branded host tuple: ${binPath}`);
272
+ }
273
+ /** True when `execPath` is the branded host copy (so a child exec'd from it
274
+ * inherits the copy's dyld dependencies). */
275
+ function isBrandedHostExec(execPath) {
276
+ try {
277
+ const hostDir = realpathSync(brandedHostDir());
278
+ return dirname(realpathSync(execPath)) === hostDir;
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ }
284
+ /** Candidate real nodes, most trustworthy first: the installed Homebrew node
285
+ * (the one an upgrade just put in place), this process's own node when it is
286
+ * not the host, PATH entries, then the marker's recorded source (an older keg
287
+ * after an upgrade, so last). */
288
+ function realNodeCandidates() {
289
+ const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
290
+ if (!isBrandedHostExec(process.execPath))
291
+ candidates.push(process.execPath);
292
+ for (const dir of (process.env['PATH'] ?? '').split(':'))
293
+ if (dir !== '')
294
+ candidates.push(join(dir, 'node'));
295
+ const marker = readMarker();
296
+ if (marker !== null && typeof marker.source === 'string')
297
+ candidates.push(marker.source);
298
+ const seen = new Set();
299
+ const real = [];
300
+ for (const candidate of candidates) {
301
+ let resolved;
302
+ try {
303
+ resolved = realpathSync(candidate);
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ if (seen.has(resolved) || isBrandedHostExec(resolved))
309
+ continue;
310
+ seen.add(resolved);
311
+ real.push(resolved);
312
+ }
313
+ return real;
314
+ }
315
+ /** A candidate is acceptable only if it starts AND speaks this process's native
316
+ * module ABI, so addons and engine features built for the running Node keep
317
+ * working — a merely-startable older nvm node is not a fallback. */
318
+ const ABI_PROBE_ARGS = ['-p', 'process.versions.modules'];
319
+ function abiMatches(stdout) {
320
+ return stdout !== null && stdout.trim() === process.versions.modules;
321
+ }
322
+ /** Find a working, ABI-compatible, non-branded `node` to rebuild from. Blocking:
323
+ * for CLI / explicit reconcile only, never the daemon's event loop (use
324
+ * findRealNodeAsync there). Returns null when none works. */
325
+ export function findRealNode() {
326
+ for (const candidate of realNodeCandidates()) {
327
+ try {
328
+ const res = spawnSync(candidate, ABI_PROBE_ARGS, { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] });
329
+ if (res.status === 0 && abiMatches(res.stdout))
330
+ return candidate;
331
+ }
332
+ catch {
333
+ /* try the next candidate */
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+ function execFileProbe(bin, args, timeoutMs) {
339
+ return new Promise((resolve) => {
340
+ try {
341
+ execFile(bin, args, { encoding: 'utf8', timeout: timeoutMs }, (error, stdout) => resolve(error === null ? stdout : null));
342
+ }
343
+ catch {
344
+ resolve(null);
345
+ }
346
+ });
347
+ }
348
+ /** Non-blocking findRealNode for the daemon's event loop. */
349
+ export async function findRealNodeAsync() {
350
+ for (const candidate of realNodeCandidates()) {
351
+ if (abiMatches(await execFileProbe(candidate, ABI_PROBE_ARGS, 5_000)))
352
+ return candidate;
353
+ }
354
+ return null;
355
+ }
356
+ // Health of the branded host as seen from a process RUNNING as that host (crtrd,
357
+ // a broker). It is refreshed asynchronously — never a blocking probe on the
358
+ // event loop — and a fork that dies forces an immediate re-probe, so the first
359
+ // failed fork heals instead of waiting out a TTL.
360
+ const HEALTH_TTL_MS = 15_000;
361
+ const HEAL_THROTTLE_MS = 60_000;
362
+ const hostHealth = { checkedAt: 0, fallback: null, probing: null, lastHealAt: 0 };
363
+ /** Forget remembered host health (tests). */
364
+ export function resetBrandedHostHealthCache() {
365
+ hostHealth.checkedAt = 0;
366
+ hostHealth.fallback = null;
367
+ hostHealth.probing = null;
368
+ hostHealth.lastHealAt = 0;
369
+ }
370
+ /** Rebuild the host from a real node in a detached child, off this process's
371
+ * event loop (copy + codesign are blocking). Throttled. Skipped in source/tsx
372
+ * runs, which never use the host. */
373
+ function healBrandedHostInBackground(realNode) {
374
+ if (import.meta.url.endsWith('.ts'))
375
+ return;
376
+ const now = Date.now();
377
+ if (now - hostHealth.lastHealAt < HEAL_THROTTLE_MS)
378
+ return;
379
+ hostHealth.lastHealAt = now;
380
+ try {
381
+ const script = `import(${JSON.stringify(import.meta.url)}).then((m) => m.ensureBrandedHost())`;
382
+ const child = spawn(realNode, ['--input-type=module', '-e', script], { detached: true, stdio: 'ignore' });
383
+ child.on('error', () => undefined);
384
+ child.unref();
385
+ }
386
+ catch {
387
+ /* healing is best-effort; the fallback node already keeps launches working */
388
+ }
389
+ }
390
+ function refreshHostHealth(host) {
391
+ if (hostHealth.probing !== null)
392
+ return hostHealth.probing;
393
+ hostHealth.probing = (async () => {
394
+ try {
395
+ if ((await execFileProbe(host, ['-e', ''], 5_000)) !== null) {
396
+ hostHealth.fallback = null;
397
+ return;
398
+ }
399
+ const real = await findRealNodeAsync();
400
+ if (real !== hostHealth.fallback && real !== null) {
401
+ emitEvent({ event: 'host.fallback.node', level: 'warn', fields: { host, node: real } });
402
+ }
403
+ hostHealth.fallback = real;
404
+ if (real !== null)
405
+ healBrandedHostInBackground(real);
406
+ }
407
+ finally {
408
+ hostHealth.checkedAt = Date.now();
409
+ hostHealth.probing = null;
410
+ }
411
+ })();
412
+ return hostHealth.probing;
413
+ }
414
+ /** The node to exec for a short-lived helper child (forked worker, bash
415
+ * supervisor, probe). Outside the branded host this is `process.execPath`
416
+ * untouched. Inside it, a host that no longer starts (dyld cannot resolve a
417
+ * moved Homebrew dependency) would abort every child, so a real node is used
418
+ * instead. Never blocks: it returns the last known answer and refreshes it in
419
+ * the background. A fork that still dies calls recheckChildNode() to learn the
420
+ * truth at once. */
421
+ export function childNodeExecPath(execPath = process.execPath) {
422
+ if (!isBrandedHostExec(execPath))
423
+ return execPath;
424
+ if (Date.now() - hostHealth.checkedAt > HEALTH_TTL_MS && hostHealth.probing === null)
425
+ void refreshHostHealth(execPath);
426
+ return hostHealth.fallback ?? execPath;
427
+ }
428
+ /** Re-probe the host now (after a child died) and return the node to retry on;
429
+ * equals `execPath` when the host is fine or no real node exists. */
430
+ export async function recheckChildNode(execPath = process.execPath) {
431
+ if (!isBrandedHostExec(execPath))
432
+ return execPath;
433
+ await refreshHostHealth(execPath);
434
+ return hostHealth.fallback ?? execPath;
435
+ }
436
+ /** Like childNodeExecPath(), but first awaits a probe no older than `maxAgeMs`
437
+ * (coalesced with any probe in flight). Use it right before a fork whose death
438
+ * would lose work (a broker, a bash job, the inventory child) so a host broken
439
+ * since the last probe is caught before the exec, not after. The probe is a
440
+ * non-blocking `node -e ''`; outside the branded host this costs nothing. */
441
+ export async function freshChildNodeExecPath(execPath = process.execPath, maxAgeMs = 1_000) {
442
+ if (!isBrandedHostExec(execPath))
443
+ return execPath;
444
+ if (hostHealth.probing === null && Date.now() - hostHealth.checkedAt >= maxAgeMs)
445
+ await refreshHostHealth(execPath);
446
+ else if (hostHealth.probing !== null)
447
+ await hostHealth.probing;
448
+ return hostHealth.fallback ?? execPath;
449
+ }
450
+ /** hostExecPath() for async launchers: inside the host it is verified fresh
451
+ * rather than taken from the cache. */
452
+ export async function hostExecPathFresh() {
453
+ if (process.env['CRTR_BRANDED_HOST'] === '0' || process.platform !== 'darwin' || import.meta.url.endsWith('.ts'))
454
+ return hostExecPath();
455
+ if (isBrandedHostExec(process.execPath))
456
+ return freshChildNodeExecPath();
457
+ return hostExecPath();
458
+ }
459
+ /** The path the engine (broker / daemon) should be launched from. On macOS in a
460
+ * compiled (production) build this is the validated branded host; otherwise —
461
+ * non-darwin (TCC is macOS-only), source/tsx (dev + the test suite: never copy
462
+ * 70 MB into the real ~/.crouter), or the explicit `CRTR_BRANDED_HOST=0`
463
+ * opt-out — it is the live node. */
464
+ export function hostExecPath() {
465
+ if (process.env['CRTR_BRANDED_HOST'] === '0')
466
+ return process.execPath;
467
+ if (process.platform !== 'darwin')
468
+ return process.execPath;
469
+ if (import.meta.url.endsWith('.ts'))
470
+ return process.execPath;
471
+ // Running AS the host (crtrd spawning a broker): never block this event loop.
472
+ // The host is used while it starts and a real node otherwise, with a
473
+ // background rebuild — see childNodeExecPath().
474
+ if (isBrandedHostExec(process.execPath))
475
+ return childNodeExecPath();
476
+ try {
477
+ return ensureBrandedHost();
478
+ }
479
+ catch (error) {
480
+ // A host that cannot be (re)built must not stop every node from launching.
481
+ // This process is a real node, so run unbranded and say so.
482
+ emitEvent({ event: 'host.fallback.node', level: 'warn', error, fields: { host: brandedHostBin(), node: process.execPath } });
483
+ return process.execPath;
484
+ }
485
+ }
@@ -0,0 +1,65 @@
1
+ import { type BrokerSignalCachedNode } from '../../../daemon/broker-signals/bus.js';
2
+ import type { BrokerExtensionStateDTO, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest, BrokerTelemetryRequest } from '../../../api/dto/broker-ops.js';
3
+ import type { AcknowledgeMailResultDTO, ClaimMailResultDTO, MailReceiptDTO } from '../../../api/dto/mail.js';
4
+ import type { BrokerFaultInputDTO, BrokerFaultRequest, BrokerFaultResultDTO, BrokerProviderRetryRequest, BrokerProviderRetryResultDTO, BrokerTurnRequest, BrokerTurnResultDTO, RecoveryStateDTO } from '../../../api/dto/recovery.js';
5
+ import type { RelaunchRootResultDTO } from '../../../api/dto/lifecycle.js';
6
+ import type { PushReportRequest } from '../../../api/dto/reports.js';
7
+ import type { BackgroundedJobDTO, BackgroundedJobRequest, DeliveryRequest, DeliveryResponse } from '../../../api/dto/delivery.js';
8
+ import type { NodeLogAppendResultDTO, NodeMessageBodyDTO, NodePushedFinalResultDTO, NodeTelemetryRecordRequest } from '../../../api/dto/node-records.js';
9
+ export declare function appendBrokerLog(nodeId: string, record: string): Promise<NodeLogAppendResultDTO>;
10
+ export declare function putBrokerTelemetry(nodeId: string, telemetry: Omit<NodeTelemetryRecordRequest, 'expected_execution_id'>): Promise<void>;
11
+ export declare function putBrokerRecap(nodeId: string, briefing: string): Promise<void>;
12
+ /** The body of a direct message the daemon spilled into this node's records. */
13
+ export declare function readBrokerPassiveMessage(nodeId: string, ref: string): Promise<NodeMessageBodyDTO>;
14
+ export declare function clearBrokerPushedFinal(nodeId: string): Promise<NodePushedFinalResultDTO>;
15
+ export declare function consumeBrokerPushedFinal(nodeId: string): Promise<NodePushedFinalResultDTO>;
16
+ export declare function startBrokerBashJob(nodeId: string, request: {
17
+ job_id: string;
18
+ cwd: string;
19
+ env: NodeJS.ProcessEnv;
20
+ }): Promise<{
21
+ pid: number;
22
+ }>;
23
+ /** Record a command the valve just backgrounded as this node's job object. */
24
+ export declare function recordBackgroundedJob(nodeId: string, jobId: string, request: BackgroundedJobRequest): Promise<BackgroundedJobDTO>;
25
+ export declare function stopBrokerBashJob(nodeId: string, jobId: string): Promise<void>;
26
+ export declare function brokerExecutionId(): string;
27
+ export declare function bindBrokerSession(nodeId: string, request: Omit<BrokerSessionBoundRequest, 'executionId'>): Promise<BrokerSessionBoundResultDTO>;
28
+ type BrokerInput<T> = T extends unknown ? Omit<T, 'expected_execution_id'> : never;
29
+ export declare function settleBroker(nodeId: string, request: BrokerInput<BrokerSettleRequest>): Promise<BrokerSettleDirective>;
30
+ export declare function completeBrokerPark(nodeId: string, request: BrokerInput<BrokerParkCompleteRequest>): Promise<BrokerSettleDirective>;
31
+ export declare function recordBrokerParkActivity(nodeId: string): Promise<BrokerParkActivityResultDTO>;
32
+ export declare function recordBrokerTelemetry(nodeId: string, request: Omit<BrokerTelemetryRequest, 'expected_execution_id'>): Promise<void>;
33
+ export declare function claimBrokerMail(nodeId: string, channel: 'wake' | 'passive' | 'deferred_run', reportBodies: Map<string, Promise<string>>): Promise<ClaimMailResultDTO>;
34
+ export declare function acknowledgeBrokerMail(nodeId: string, deliveries: MailReceiptDTO[]): Promise<AcknowledgeMailResultDTO>;
35
+ export declare function recordBrokerTurn(nodeId: string, request: Omit<BrokerTurnRequest, 'expected_execution_id'>): Promise<BrokerTurnResultDTO>;
36
+ export declare function mutateBrokerProviderRetry(nodeId: string, request: Omit<BrokerProviderRetryRequest, 'expected_execution_id'>): Promise<BrokerProviderRetryResultDTO>;
37
+ export declare function mutateBrokerFault(nodeId: string, request: Omit<BrokerFaultRequest, 'expected_execution_id'>): Promise<BrokerFaultResultDTO>;
38
+ export declare function brokerRecovery(nodeId: string): Promise<RecoveryStateDTO>;
39
+ /** Node-scoped fault writes are for non-broker clients such as attach transport.
40
+ * Broker-owned recovery mutations use the execution-fenced wrappers above. */
41
+ export declare function recordNodeFault(nodeId: string, input: BrokerFaultInputDTO): Promise<BrokerFaultResultDTO>;
42
+ export declare function clearNodeFault(nodeId: string, opts?: {
43
+ link?: BrokerFaultInputDTO['link'];
44
+ preserveEpisode?: boolean;
45
+ }): Promise<boolean>;
46
+ /** Fixed canvas projection for broker extension rendering. It deliberately
47
+ * exposes no generic node/database facade. */
48
+ export declare function commitBrokerModel(nodeId: string, request: BrokerInput<BrokerModelCommitRequest>): Promise<BrokerModelCommitResultDTO>;
49
+ export declare function brokerExtensionState(nodeId: string): Promise<BrokerExtensionStateDTO>;
50
+ /** Only node-owned fields are cached. A disconnected or stopped stream cannot
51
+ * guarantee freshness, so events at those boundaries read the daemon instead. */
52
+ export declare function cachedBrokerNode(nodeId: string): Promise<BrokerSignalCachedNode>;
53
+ /** The node's one delivery route: every rule-driven document delivery the
54
+ * broker applies, recorded by crtrd in the same request. */
55
+ export declare function brokerDelivery(nodeId: string, request: BrokerInput<DeliveryRequest>): Promise<DeliveryResponse>;
56
+ export declare function commitBrokerGeneratedName(nodeId: string, request: BrokerInput<BrokerGeneratedNameRequest>): Promise<BrokerGeneratedNameResultDTO>;
57
+ export declare function commitBrokerPersonaAck(nodeId: string, request: BrokerInput<BrokerPersonaAckRequest>): Promise<BrokerPersonaAckResultDTO>;
58
+ /** Reports still use the canonical daemon report handler; this broker adapter
59
+ * only preserves terminal structured output's idempotent finalization result. */
60
+ export declare function publishBrokerReport(nodeId: string, request: PushReportRequest): Promise<void>;
61
+ /** Root `/new` remains on the dedicated relaunch operation because it launches
62
+ * a replacement broker and tears down this broker. Callers must not await it
63
+ * from Pi's session-start dispatch. */
64
+ export declare function relaunchBrokerRoot(nodeId: string): Promise<RelaunchRootResultDTO>;
65
+ export {};