agent-tempo 1.2.0 → 1.4.0

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 (281) hide show
  1. package/CLAUDE.md +253 -219
  2. package/LICENSE +21 -21
  3. package/README.md +293 -289
  4. package/assets/icon-dark.svg +9 -9
  5. package/assets/icon.svg +9 -9
  6. package/assets/logo-dark.svg +11 -11
  7. package/assets/logo-light.svg +11 -11
  8. package/dashboard/README.md +91 -91
  9. package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
  10. package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
  11. package/dashboard/dist/index.html +20 -20
  12. package/dashboard/package.json +47 -47
  13. package/dist/activities/outbox.d.ts +30 -1
  14. package/dist/activities/outbox.js +96 -3
  15. package/dist/adapters/base.js +5 -0
  16. package/dist/adapters/copilot/adapter.js +12 -1
  17. package/dist/adapters/index.d.ts +1 -1
  18. package/dist/adapters/index.js +7 -0
  19. package/dist/adapters/pi/adapter.d.ts +2 -0
  20. package/dist/adapters/pi/adapter.js +43 -0
  21. package/dist/adapters/pi/index.d.ts +16 -0
  22. package/dist/adapters/pi/index.js +10 -0
  23. package/dist/cli/global-wrapper.d.ts +19 -0
  24. package/dist/cli/global-wrapper.js +169 -0
  25. package/dist/cli/help-text.js +97 -97
  26. package/dist/cli/startup.js +11 -0
  27. package/dist/cli/upgrade-command.js +81 -81
  28. package/dist/cli.js +12 -0
  29. package/dist/client/core.js +9 -2
  30. package/dist/client/interface.d.ts +6 -0
  31. package/dist/config.d.ts +79 -0
  32. package/dist/config.js +74 -0
  33. package/dist/daemon.js +37 -1
  34. package/dist/http/aggregate.d.ts +22 -1
  35. package/dist/http/aggregate.js +41 -0
  36. package/dist/http/auth.d.ts +94 -8
  37. package/dist/http/auth.js +93 -9
  38. package/dist/http/body.d.ts +4 -1
  39. package/dist/http/body.js +6 -3
  40. package/dist/http/event-bus.js +1 -0
  41. package/dist/http/event-types.d.ts +34 -2
  42. package/dist/http/event-types.js +1 -0
  43. package/dist/http/gate-audit.d.ts +12 -0
  44. package/dist/http/gate-audit.js +95 -0
  45. package/dist/http/gate-registry.d.ts +167 -0
  46. package/dist/http/gate-registry.js +163 -0
  47. package/dist/http/gate-routes.d.ts +48 -0
  48. package/dist/http/gate-routes.js +102 -0
  49. package/dist/http/ingest-registry.d.ts +30 -0
  50. package/dist/http/ingest-registry.js +108 -0
  51. package/dist/http/inner-loop-routes.d.ts +66 -0
  52. package/dist/http/inner-loop-routes.js +182 -0
  53. package/dist/http/inner-loop.d.ts +92 -0
  54. package/dist/http/inner-loop.js +155 -0
  55. package/dist/http/server.d.ts +38 -3
  56. package/dist/http/server.js +211 -6
  57. package/dist/http/snapshot.d.ts +6 -0
  58. package/dist/http/snapshot.js +6 -0
  59. package/dist/pi/cue-pump.d.ts +61 -0
  60. package/dist/pi/cue-pump.js +95 -0
  61. package/dist/pi/extension.d.ts +45 -0
  62. package/dist/pi/extension.js +407 -0
  63. package/dist/pi/gate-client.d.ts +54 -0
  64. package/dist/pi/gate-client.js +136 -0
  65. package/dist/pi/headless.d.ts +85 -0
  66. package/dist/pi/headless.js +224 -0
  67. package/dist/pi/index.d.ts +28 -0
  68. package/dist/pi/index.js +43 -0
  69. package/dist/pi/inner-loop-client.d.ts +67 -0
  70. package/dist/pi/inner-loop-client.js +164 -0
  71. package/dist/pi/inner-loop-publisher.d.ts +187 -0
  72. package/dist/pi/inner-loop-publisher.js +236 -0
  73. package/dist/pi/lazy-proxy.d.ts +37 -0
  74. package/dist/pi/lazy-proxy.js +55 -0
  75. package/dist/pi/mission-control/actions.d.ts +48 -0
  76. package/dist/pi/mission-control/actions.js +98 -0
  77. package/dist/pi/mission-control/board.d.ts +53 -0
  78. package/dist/pi/mission-control/board.js +104 -0
  79. package/dist/pi/mission-control/extension.d.ts +44 -0
  80. package/dist/pi/mission-control/extension.js +251 -0
  81. package/dist/pi/mission-control/index.d.ts +15 -0
  82. package/dist/pi/mission-control/index.js +32 -0
  83. package/dist/pi/mission-control/inner-tail.d.ts +48 -0
  84. package/dist/pi/mission-control/inner-tail.js +76 -0
  85. package/dist/pi/mission-control/pi-ui.d.ts +43 -0
  86. package/dist/pi/mission-control/pi-ui.js +10 -0
  87. package/dist/pi/mission-control/render.d.ts +6 -0
  88. package/dist/pi/mission-control/render.js +95 -0
  89. package/dist/pi/phase-driver.d.ts +74 -0
  90. package/dist/pi/phase-driver.js +122 -0
  91. package/dist/pi/pi-types.d.ts +208 -0
  92. package/dist/pi/pi-types.js +21 -0
  93. package/dist/pi/probe.d.ts +80 -0
  94. package/dist/pi/probe.js +154 -0
  95. package/dist/pi/render-tools.d.ts +17 -0
  96. package/dist/pi/render-tools.js +51 -0
  97. package/dist/pi/reset-pump.d.ts +47 -0
  98. package/dist/pi/reset-pump.js +85 -0
  99. package/dist/pi/tool-capability.d.ts +60 -0
  100. package/dist/pi/tool-capability.js +156 -0
  101. package/dist/pi/workflow-client.d.ts +158 -0
  102. package/dist/pi/workflow-client.js +289 -0
  103. package/dist/pi/zod-to-typebox.d.ts +74 -0
  104. package/dist/pi/zod-to-typebox.js +191 -0
  105. package/dist/scripts/verify-daemon-isolation-guard.js +24 -24
  106. package/dist/server-tools.d.ts +2 -0
  107. package/dist/server-tools.js +50 -46
  108. package/dist/server.js +4 -0
  109. package/dist/spawn.d.ts +55 -0
  110. package/dist/spawn.js +84 -12
  111. package/dist/tools/agent-types.d.ts +2 -2
  112. package/dist/tools/agent-types.js +22 -17
  113. package/dist/tools/attachment-info.d.ts +2 -2
  114. package/dist/tools/attachment-info.js +38 -33
  115. package/dist/tools/broadcast.d.ts +2 -2
  116. package/dist/tools/broadcast.js +69 -64
  117. package/dist/tools/cancel-stage.d.ts +2 -2
  118. package/dist/tools/cancel-stage.js +20 -15
  119. package/dist/tools/clear-state.d.ts +2 -2
  120. package/dist/tools/clear-state.js +25 -20
  121. package/dist/tools/coat-check-evict.d.ts +2 -2
  122. package/dist/tools/coat-check-evict.js +30 -25
  123. package/dist/tools/coat-check-get.d.ts +2 -2
  124. package/dist/tools/coat-check-get.js +39 -34
  125. package/dist/tools/coat-check-list.d.ts +2 -2
  126. package/dist/tools/coat-check-list.js +48 -43
  127. package/dist/tools/coat-check-put.d.ts +2 -2
  128. package/dist/tools/coat-check-put.js +41 -36
  129. package/dist/tools/cue.d.ts +2 -2
  130. package/dist/tools/cue.js +57 -52
  131. package/dist/tools/descriptor.d.ts +72 -0
  132. package/dist/tools/descriptor.js +39 -0
  133. package/dist/tools/destroy.d.ts +2 -2
  134. package/dist/tools/destroy.js +153 -148
  135. package/dist/tools/ensemble.d.ts +2 -2
  136. package/dist/tools/ensemble.js +71 -66
  137. package/dist/tools/evaluate-gate.d.ts +2 -2
  138. package/dist/tools/evaluate-gate.js +33 -27
  139. package/dist/tools/fetch-state.d.ts +2 -2
  140. package/dist/tools/fetch-state.js +43 -38
  141. package/dist/tools/gates.d.ts +2 -2
  142. package/dist/tools/gates.js +39 -34
  143. package/dist/tools/hosts.d.ts +2 -2
  144. package/dist/tools/hosts.js +25 -20
  145. package/dist/tools/listen.d.ts +2 -2
  146. package/dist/tools/listen.js +23 -18
  147. package/dist/tools/load-lineup.d.ts +2 -2
  148. package/dist/tools/load-lineup.js +324 -319
  149. package/dist/tools/migrate.d.ts +2 -2
  150. package/dist/tools/migrate.js +45 -40
  151. package/dist/tools/pause.d.ts +2 -2
  152. package/dist/tools/pause.js +34 -29
  153. package/dist/tools/play.d.ts +2 -2
  154. package/dist/tools/play.js +53 -48
  155. package/dist/tools/quality-gate.d.ts +2 -2
  156. package/dist/tools/quality-gate.js +26 -21
  157. package/dist/tools/recall.d.ts +2 -2
  158. package/dist/tools/recall.js +32 -27
  159. package/dist/tools/recruit.d.ts +2 -2
  160. package/dist/tools/recruit.js +325 -256
  161. package/dist/tools/release.d.ts +2 -2
  162. package/dist/tools/release.js +85 -80
  163. package/dist/tools/report.d.ts +2 -2
  164. package/dist/tools/report.js +28 -23
  165. package/dist/tools/reset.d.ts +3 -0
  166. package/dist/tools/reset.js +51 -0
  167. package/dist/tools/restart.d.ts +2 -2
  168. package/dist/tools/restart.js +51 -46
  169. package/dist/tools/restore.d.ts +2 -2
  170. package/dist/tools/restore.js +76 -71
  171. package/dist/tools/save-lineup.d.ts +2 -2
  172. package/dist/tools/save-lineup.js +32 -27
  173. package/dist/tools/save-state.d.ts +2 -2
  174. package/dist/tools/save-state.js +43 -38
  175. package/dist/tools/schedule.d.ts +2 -2
  176. package/dist/tools/schedule.js +133 -128
  177. package/dist/tools/schedules.d.ts +2 -2
  178. package/dist/tools/schedules.js +41 -36
  179. package/dist/tools/set-ensemble-description.d.ts +2 -2
  180. package/dist/tools/set-ensemble-description.js +26 -21
  181. package/dist/tools/set-name.d.ts +2 -2
  182. package/dist/tools/set-name.js +38 -33
  183. package/dist/tools/set-part.d.ts +2 -2
  184. package/dist/tools/set-part.js +20 -15
  185. package/dist/tools/shutdown.d.ts +2 -2
  186. package/dist/tools/shutdown.js +39 -34
  187. package/dist/tools/stage.d.ts +2 -2
  188. package/dist/tools/stage.js +28 -23
  189. package/dist/tools/stages.d.ts +2 -2
  190. package/dist/tools/stages.js +36 -31
  191. package/dist/tools/unschedule.d.ts +2 -2
  192. package/dist/tools/unschedule.js +30 -25
  193. package/dist/tools/who-am-i.d.ts +2 -2
  194. package/dist/tools/who-am-i.js +36 -31
  195. package/dist/tools/worktree.d.ts +2 -2
  196. package/dist/tools/worktree.js +134 -129
  197. package/dist/tui/index.js +6 -6
  198. package/dist/types.d.ts +47 -2
  199. package/dist/types.js +1 -1
  200. package/dist/utils/default-part.js +1 -0
  201. package/dist/utils/grpc-shutdown-guard.d.ts +52 -0
  202. package/dist/utils/grpc-shutdown-guard.js +88 -0
  203. package/dist/utils/sdk-probe.d.ts +23 -0
  204. package/dist/utils/sdk-probe.js +46 -7
  205. package/dist/worker.d.ts +3 -1
  206. package/dist/worker.js +6 -2
  207. package/dist/workflows/session.js +70 -2
  208. package/dist/workflows/signals.d.ts +32 -2
  209. package/dist/workflows/signals.js +25 -2
  210. package/examples/agents/tempo-composer.md +56 -56
  211. package/examples/agents/tempo-conductor.md +117 -117
  212. package/examples/agents/tempo-critic.md +73 -73
  213. package/examples/agents/tempo-improv.md +74 -74
  214. package/examples/agents/tempo-liner.md +75 -75
  215. package/examples/agents/tempo-roadie.md +61 -61
  216. package/examples/agents/tempo-soloist.md +71 -71
  217. package/examples/agents/tempo-tuner.md +94 -94
  218. package/examples/ensembles/tempo-big-band.yaml +146 -146
  219. package/examples/ensembles/tempo-dev-team.yaml +58 -58
  220. package/examples/ensembles/tempo-headless-jam.yaml +77 -77
  221. package/examples/ensembles/tempo-jam-session.yaml +41 -41
  222. package/examples/ensembles/tempo-mock-jam.yaml +79 -79
  223. package/examples/ensembles/tempo-review-squad.yaml +32 -32
  224. package/package.json +176 -173
  225. package/packaging/launchd/com.agent.tempo.plist +46 -46
  226. package/packaging/systemd/agent-tempo.service +32 -32
  227. package/packaging/windows/install-task.ps1 +71 -71
  228. package/scenarios/conductor-recruit-mock.yaml +33 -33
  229. package/scenarios/echo-roundtrip.yaml +15 -15
  230. package/scenarios/multi-player-handoff.yaml +38 -38
  231. package/scenarios/recruit-cascade.yaml +38 -38
  232. package/scenarios/two-player-conversation.yaml +33 -33
  233. package/workflow-bundle.js +97 -6
  234. package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
  235. package/dist/activities/claude-stop.d.ts +0 -21
  236. package/dist/activities/claude-stop.js +0 -94
  237. package/dist/channel.d.ts +0 -3
  238. package/dist/channel.js +0 -48
  239. package/dist/copilot-bridge.d.ts +0 -22
  240. package/dist/copilot-bridge.js +0 -565
  241. package/dist/scripts/258-spotcheck.js +0 -303
  242. package/dist/tools/detach.d.ts +0 -4
  243. package/dist/tools/detach.js +0 -45
  244. package/dist/tools/encore.d.ts +0 -4
  245. package/dist/tools/encore.js +0 -31
  246. package/dist/tools/helpers.d.ts +0 -21
  247. package/dist/tools/helpers.js +0 -25
  248. package/dist/tools/pause-ensemble.d.ts +0 -4
  249. package/dist/tools/pause-ensemble.js +0 -58
  250. package/dist/tools/resume-ensemble.d.ts +0 -4
  251. package/dist/tools/resume-ensemble.js +0 -79
  252. package/dist/tools/stop.d.ts +0 -4
  253. package/dist/tools/stop.js +0 -29
  254. package/dist/tui/client.d.ts +0 -6
  255. package/dist/tui/client.js +0 -9
  256. package/dist/tui/components/ActivityLog.d.ts +0 -16
  257. package/dist/tui/components/ActivityLog.js +0 -36
  258. package/dist/tui/components/CommandOverlay.d.ts +0 -15
  259. package/dist/tui/components/CommandOverlay.js +0 -34
  260. package/dist/tui/components/ConductorChat.d.ts +0 -16
  261. package/dist/tui/components/ConductorChat.js +0 -32
  262. package/dist/tui/components/EnsembleListView.d.ts +0 -14
  263. package/dist/tui/components/EnsembleListView.js +0 -32
  264. package/dist/tui/components/EnsemblePanel.d.ts +0 -12
  265. package/dist/tui/components/EnsemblePanel.js +0 -40
  266. package/dist/tui/components/InputBar.d.ts +0 -13
  267. package/dist/tui/components/InputBar.js +0 -58
  268. package/dist/tui/components/ScheduleOverlay.d.ts +0 -13
  269. package/dist/tui/components/ScheduleOverlay.js +0 -113
  270. package/dist/tui/components/TopBar.d.ts +0 -12
  271. package/dist/tui/components/TopBar.js +0 -15
  272. package/dist/tui/core-api.d.ts +0 -26
  273. package/dist/tui/core-api.js +0 -67
  274. package/dist/tui/hooks/useEnsembleDiscovery.d.ts +0 -3
  275. package/dist/tui/hooks/useEnsembleDiscovery.js +0 -30
  276. package/dist/tui/hooks/useMaestroPoller.d.ts +0 -3
  277. package/dist/tui/hooks/useMaestroPoller.js +0 -36
  278. package/dist/tui/hooks/useSendCommand.d.ts +0 -7
  279. package/dist/tui/hooks/useSendCommand.js +0 -29
  280. package/dist/utils/bg-preflight.d.ts +0 -25
  281. package/dist/utils/bg-preflight.js +0 -154
package/dist/config.d.ts CHANGED
@@ -48,6 +48,37 @@ export declare const ENV: {
48
48
  * sandboxed contexts. Mutually exclusive with {@link PERMISSION_MODE}.
49
49
  */
50
50
  readonly DANGEROUSLY_SKIP_PERMISSIONS: "AGENT_TEMPO_DANGEROUSLY_SKIP_PERMISSIONS";
51
+ /**
52
+ * Phase 3a — headless Pi runtime model selector. Pi takes a `provider/model`
53
+ * string (e.g. `anthropic/claude-opus-4-7`); absent → Pi's own default
54
+ * provider/model (the 3a anthropic-default path). Recruit `model` arg →
55
+ * this env → Pi default.
56
+ */
57
+ readonly PI_MODEL: "AGENT_TEMPO_PI_MODEL";
58
+ /**
59
+ * Phase 3a — headless Pi restart-resume. The daemon reads `metadata.sessionId`
60
+ * (the Pi conversation id the player was in when it died) and passes it here;
61
+ * the headless entry resumes via Pi `continueSession(<id>)`. Absent on a fresh
62
+ * recruit → a new Pi session.
63
+ */
64
+ readonly PI_CONTINUE_SESSION: "AGENT_TEMPO_PI_CONTINUE_SESSION";
65
+ /**
66
+ * Phase 3a / MD-C — headless Pi tool-access policy. One of
67
+ * `restricted` (default; Bash/shell/exec HARD-BLOCKED) | `standard` (scoped
68
+ * Bash) | `full` (unsandboxed; admin-gated at recruit). Read by the Pi
69
+ * extension's `tool_call` gate (mode='headless' only). Mirrors
70
+ * {@link PERMISSION_MODE}'s threading.
71
+ */
72
+ readonly TOOL_ACCESS: "AGENT_TEMPO_TOOL_ACCESS";
73
+ /**
74
+ * 3c Tier-2 ingest auth. The daemon mints a per-player ingest token (scoped to
75
+ * the session workflowId) BEFORE spawning a headless Pi player and threads it
76
+ * into the subprocess env here. The player's inner-loop publisher presents it
77
+ * on `POST /inner/ingest` + `GET /inner/presence` (loopback), where the daemon
78
+ * validates it against the URL-derived workflowId (cross-player-spoof guard).
79
+ * Absent → the publisher's HTTP client is a no-op (no fine-tail forwarding).
80
+ */
81
+ readonly INGEST_TOKEN: "AGENT_TEMPO_INGEST_TOKEN";
51
82
  /**
52
83
  * v0.25 PR-D attachment resume plumbing. When `restart` / `migrate`
53
84
  * enqueues a spawn outbox entry, the workflow passes the pre-claimed
@@ -72,6 +103,16 @@ export declare const ENV: {
72
103
  readonly DAEMON_PORT: "AGENT_TEMPO_DAEMON_PORT";
73
104
  readonly CORS_ORIGINS: "AGENT_TEMPO_CORS_ORIGINS";
74
105
  readonly SSE_MAX_CONNECTIONS: "AGENT_TEMPO_SSE_MAX_CONNECTIONS";
106
+ /**
107
+ * 3e RBAC (MD-E). Two-token model: the READ token (T1 — observe) may live in
108
+ * env or config.json and auto-generates; the ADMIN token (T1+T2+T3 — mutate +
109
+ * supervisory gate/inner) is ENV-VAR-ONLY (never config.json/disk, never
110
+ * auto-generated). `TLS_ACKNOWLEDGED=1` suppresses the non-loopback-bind
111
+ * plaintext-HTTP startup warning.
112
+ */
113
+ readonly HTTP_READ_TOKEN: "AGENT_TEMPO_HTTP_READ_TOKEN";
114
+ readonly HTTP_ADMIN_TOKEN: "AGENT_TEMPO_HTTP_ADMIN_TOKEN";
115
+ readonly TLS_ACKNOWLEDGED: "AGENT_TEMPO_TLS_ACKNOWLEDGED";
75
116
  /**
76
117
  * Dev profile gate (ADR 0014 §5.2). One source of truth — every layer
77
118
  * (paths, namespace, port, task queue, banner, registry gating) consults
@@ -115,8 +156,19 @@ export interface PersistedConfig {
115
156
  * a request with a non-loopback `Origin`) and no token is set:
116
157
  * `crypto.randomBytes(32).toString('base64url')`, 0600 on POSIX.
117
158
  * Rotation = delete this field; next daemon boot regenerates.
159
+ *
160
+ * 3e: this LEGACY single token is migrated to the READ tier (T1) — a daemon
161
+ * with only `httpToken` set keeps read access and emits a one-time startup
162
+ * warning to set an admin token for writes/gate/inner. Prefer `readToken`.
118
163
  */
119
164
  httpToken?: string;
165
+ /**
166
+ * 3e RBAC — the READ-tier (T1) bearer token. Env `AGENT_TEMPO_HTTP_READ_TOKEN`
167
+ * takes precedence over this; auto-generated here on first bearer-mode boot if
168
+ * neither is set. The ADMIN token is deliberately ABSENT from this file (it is
169
+ * env-var-only, never persisted).
170
+ */
171
+ readToken?: string;
120
172
  }
121
173
  /**
122
174
  * Dev profile defaults — one switch (`--dev` top-level flag, or
@@ -278,6 +330,33 @@ export declare function parseTemporalYaml(content: string): PersistedConfig;
278
330
  * for empty/unset values so callers can use it as a source-aware default.
279
331
  */
280
332
  export declare function parseAgent(value: string | undefined, source: ConfigSource): AgentType;
333
+ /**
334
+ * Result of {@link parsePiProviderModel}: the parsed parts, OR an `{ error }`
335
+ * describing why the selector is malformed. Non-throwing by design — a pure
336
+ * mapper returning a discriminated union (the recruit wiring branches
337
+ * `if ('error' in r) return fail(r.error)`, no try/catch).
338
+ */
339
+ export type ProviderModel = {
340
+ provider: string;
341
+ model: string;
342
+ } | {
343
+ error: string;
344
+ };
345
+ /**
346
+ * Parse a Pi provider/model selector (e.g. `"github-copilot/gpt-4o"`) into its
347
+ * `{ provider, model }` parts for Pi's `createAgentSession` model option.
348
+ *
349
+ * Provider-agnostic: the segment before the FIRST `/` is the provider id,
350
+ * passed through VERBATIM (Copilot's pi-ai provider id is literally
351
+ * `github-copilot` — no normalization needed); everything after is the model
352
+ * id, which may itself contain `/` (e.g. `openrouter/anthropic/claude`).
353
+ *
354
+ * Fail-loud (no silent default): returns `{ error }` — never a fallback model —
355
+ * when the selector has no `/`, an empty provider, or an empty model. A bare
356
+ * provider with no model is rejected here; omitting the recruit `model` arg
357
+ * ENTIRELY is a different path (Pi's own default), handled upstream, not here.
358
+ */
359
+ export declare function parsePiProviderModel(model: string): ProviderModel;
281
360
  /** CLI flag overrides — passed down from the arg parser. */
282
361
  export interface CliOverrides {
283
362
  temporalAddress?: string;
package/dist/config.js CHANGED
@@ -11,6 +11,7 @@ exports.saveConfigFile = saveConfigFile;
11
11
  exports.loadTemporalCliConfig = loadTemporalCliConfig;
12
12
  exports.parseTemporalYaml = parseTemporalYaml;
13
13
  exports.parseAgent = parseAgent;
14
+ exports.parsePiProviderModel = parsePiProviderModel;
14
15
  exports.getConfig = getConfig;
15
16
  exports.getConfigWithSources = getConfigWithSources;
16
17
  exports.hostTaskQueue = hostTaskQueue;
@@ -78,6 +79,37 @@ exports.ENV = {
78
79
  * sandboxed contexts. Mutually exclusive with {@link PERMISSION_MODE}.
79
80
  */
80
81
  DANGEROUSLY_SKIP_PERMISSIONS: 'AGENT_TEMPO_DANGEROUSLY_SKIP_PERMISSIONS',
82
+ /**
83
+ * Phase 3a — headless Pi runtime model selector. Pi takes a `provider/model`
84
+ * string (e.g. `anthropic/claude-opus-4-7`); absent → Pi's own default
85
+ * provider/model (the 3a anthropic-default path). Recruit `model` arg →
86
+ * this env → Pi default.
87
+ */
88
+ PI_MODEL: 'AGENT_TEMPO_PI_MODEL',
89
+ /**
90
+ * Phase 3a — headless Pi restart-resume. The daemon reads `metadata.sessionId`
91
+ * (the Pi conversation id the player was in when it died) and passes it here;
92
+ * the headless entry resumes via Pi `continueSession(<id>)`. Absent on a fresh
93
+ * recruit → a new Pi session.
94
+ */
95
+ PI_CONTINUE_SESSION: 'AGENT_TEMPO_PI_CONTINUE_SESSION',
96
+ /**
97
+ * Phase 3a / MD-C — headless Pi tool-access policy. One of
98
+ * `restricted` (default; Bash/shell/exec HARD-BLOCKED) | `standard` (scoped
99
+ * Bash) | `full` (unsandboxed; admin-gated at recruit). Read by the Pi
100
+ * extension's `tool_call` gate (mode='headless' only). Mirrors
101
+ * {@link PERMISSION_MODE}'s threading.
102
+ */
103
+ TOOL_ACCESS: 'AGENT_TEMPO_TOOL_ACCESS',
104
+ /**
105
+ * 3c Tier-2 ingest auth. The daemon mints a per-player ingest token (scoped to
106
+ * the session workflowId) BEFORE spawning a headless Pi player and threads it
107
+ * into the subprocess env here. The player's inner-loop publisher presents it
108
+ * on `POST /inner/ingest` + `GET /inner/presence` (loopback), where the daemon
109
+ * validates it against the URL-derived workflowId (cross-player-spoof guard).
110
+ * Absent → the publisher's HTTP client is a no-op (no fine-tail forwarding).
111
+ */
112
+ INGEST_TOKEN: 'AGENT_TEMPO_INGEST_TOKEN',
81
113
  /**
82
114
  * v0.25 PR-D attachment resume plumbing. When `restart` / `migrate`
83
115
  * enqueues a spawn outbox entry, the workflow passes the pre-claimed
@@ -102,6 +134,16 @@ exports.ENV = {
102
134
  DAEMON_PORT: 'AGENT_TEMPO_DAEMON_PORT',
103
135
  CORS_ORIGINS: 'AGENT_TEMPO_CORS_ORIGINS',
104
136
  SSE_MAX_CONNECTIONS: 'AGENT_TEMPO_SSE_MAX_CONNECTIONS',
137
+ /**
138
+ * 3e RBAC (MD-E). Two-token model: the READ token (T1 — observe) may live in
139
+ * env or config.json and auto-generates; the ADMIN token (T1+T2+T3 — mutate +
140
+ * supervisory gate/inner) is ENV-VAR-ONLY (never config.json/disk, never
141
+ * auto-generated). `TLS_ACKNOWLEDGED=1` suppresses the non-loopback-bind
142
+ * plaintext-HTTP startup warning.
143
+ */
144
+ HTTP_READ_TOKEN: 'AGENT_TEMPO_HTTP_READ_TOKEN',
145
+ HTTP_ADMIN_TOKEN: 'AGENT_TEMPO_HTTP_ADMIN_TOKEN',
146
+ TLS_ACKNOWLEDGED: 'AGENT_TEMPO_TLS_ACKNOWLEDGED',
105
147
  /**
106
148
  * Dev profile gate (ADR 0014 §5.2). One source of truth — every layer
107
149
  * (paths, namespace, port, task queue, banner, registry gating) consults
@@ -420,6 +462,38 @@ function parseAgent(value, source) {
420
462
  }
421
463
  return value;
422
464
  }
465
+ /**
466
+ * Parse a Pi provider/model selector (e.g. `"github-copilot/gpt-4o"`) into its
467
+ * `{ provider, model }` parts for Pi's `createAgentSession` model option.
468
+ *
469
+ * Provider-agnostic: the segment before the FIRST `/` is the provider id,
470
+ * passed through VERBATIM (Copilot's pi-ai provider id is literally
471
+ * `github-copilot` — no normalization needed); everything after is the model
472
+ * id, which may itself contain `/` (e.g. `openrouter/anthropic/claude`).
473
+ *
474
+ * Fail-loud (no silent default): returns `{ error }` — never a fallback model —
475
+ * when the selector has no `/`, an empty provider, or an empty model. A bare
476
+ * provider with no model is rejected here; omitting the recruit `model` arg
477
+ * ENTIRELY is a different path (Pi's own default), handled upstream, not here.
478
+ */
479
+ function parsePiProviderModel(model) {
480
+ const raw = model.trim();
481
+ const slash = raw.indexOf('/');
482
+ if (slash < 0) {
483
+ return {
484
+ error: `model "${model}" must be a "provider/model" selector (e.g. "github-copilot/gpt-4o") — no "/" found.`,
485
+ };
486
+ }
487
+ const provider = raw.slice(0, slash).trim();
488
+ const modelId = raw.slice(slash + 1).trim();
489
+ if (!provider) {
490
+ return { error: `model "${model}" has an empty provider before "/" — expected e.g. "github-copilot/gpt-4o".` };
491
+ }
492
+ if (!modelId) {
493
+ return { error: `model "${model}" has an empty model after "/" — specify a model, e.g. "github-copilot/gpt-4o".` };
494
+ }
495
+ return { provider, model: modelId };
496
+ }
423
497
  /**
424
498
  * Resolve `defaultAgent` through the standard precedence chain and validate
425
499
  * against the {@link AgentType} union. Each step passes its own source tag
package/dist/daemon.js CHANGED
@@ -66,6 +66,11 @@ const config_1 = require("./config");
66
66
  const dev_banner_1 = require("./cli/dev-banner");
67
67
  const worker_1 = require("./worker");
68
68
  const connection_1 = require("./connection");
69
+ const inner_loop_1 = require("./http/inner-loop");
70
+ const ingest_registry_1 = require("./http/ingest-registry");
71
+ const gate_registry_1 = require("./http/gate-registry");
72
+ const gate_audit_1 = require("./http/gate-audit");
73
+ const grpc_shutdown_guard_1 = require("./utils/grpc-shutdown-guard");
69
74
  const daemon_1 = require("./cli/daemon");
70
75
  const client_3 = require("./client");
71
76
  const orphans_1 = require("./reconcile/orphans");
@@ -684,6 +689,10 @@ function startCleanupLoop(client, daemonConfig, hostname = os.hostname()) {
684
689
  };
685
690
  }
686
691
  async function main() {
692
+ // Neutralize the Temporal/grpc-js "Channel has been shut down" retry-after-
693
+ // close race so a stray retry timer can't kill the long-lived daemon. See
694
+ // src/utils/grpc-shutdown-guard.ts.
695
+ (0, grpc_shutdown_guard_1.installGrpcShutdownGuard)();
687
696
  // ADR 0014 §5.4 / gate 4 — dev daemon log self-identifies. Banner fires
688
697
  // first so it lands at the top of `~/.agent-tempo-dev/daemon.log` for
689
698
  // grep-friendly identification regardless of subsequent log volume.
@@ -823,6 +832,19 @@ async function main() {
823
832
  // #94/#95 PR-2 — aggregate poll loop + per-ensemble buses. Owned by
824
833
  // the daemon process; `close()` drains every per-ensemble bus.
825
834
  let aggregateRunner = null;
835
+ // 3c Tier-2 — daemon-owned singletons shared between the Temporal worker
836
+ // (outbox pi-spawn mints / destroy revokes) and the HTTP server (/inner SSE
837
+ // + /inner/ingest validation). Both the worker and startHttpServer run in
838
+ // THIS process, so one instance each suffices. Constructed eagerly (no I/O)
839
+ // so the shutdown handler — declared just below — can drain them.
840
+ const innerLoop = new inner_loop_1.InnerLoopRegistry();
841
+ const ingestTokens = new ingest_registry_1.IngestTokenRegistry();
842
+ // 3d MD-G — the operator-gate registry, same daemon-owned-singleton pattern.
843
+ // Audit sink = the append-only JSONL writer; publishToInner = innerLoop.publish
844
+ // (the DI that emits gate_resolved on the player's /inner stream without a
845
+ // GateRegistry↔inner-loop circular import).
846
+ const gate = new gate_registry_1.GateRegistry((0, gate_audit_1.createGateAuditSink)(), Date.now, undefined, // default 45s auto-allow
847
+ (workflowId, frame) => innerLoop.publish(workflowId, frame));
826
848
  const shutdown = () => {
827
849
  if (shuttingDown)
828
850
  return;
@@ -856,6 +878,12 @@ async function main() {
856
878
  // sockets — preventing wasted work in the drain window.
857
879
  aggregateRunner?.close();
858
880
  httpServerHandle?.close().catch((err) => log('http close error (non-fatal):', err instanceof Error ? err.message : err));
881
+ // 3c Tier-2 — clear-all on shutdown: drop every minted ingest token (no
882
+ // dead token outlives the daemon) and close every open /inner subscriber
883
+ // (streams end cleanly rather than dangling).
884
+ ingestTokens.revokeAll();
885
+ innerLoop.close();
886
+ gate.clear();
859
887
  sharedWorker?.shutdown();
860
888
  hostWorker?.shutdown();
861
889
  };
@@ -863,7 +891,7 @@ async function main() {
863
891
  process.on('SIGINT', shutdown);
864
892
  // Create workers (signal handlers already active via mutable refs)
865
893
  log(`Connecting to Temporal at ${config.temporalAddress} (namespace: ${config.temporalNamespace})`);
866
- const workers = await (0, worker_1.createWorkers)(config);
894
+ const workers = await (0, worker_1.createWorkers)(config, ingestTokens, gate);
867
895
  sharedWorker = workers.sharedWorker;
868
896
  hostWorker = workers.hostWorker;
869
897
  log('Workers created — processing tasks');
@@ -948,6 +976,14 @@ async function main() {
948
976
  taskQueue: config.taskQueue,
949
977
  version: daemonVersion(),
950
978
  aggregate: aggregateRunner,
979
+ // 3c Tier-2 — same singletons the worker's outbox activities use, so
980
+ // the operator /inner SSE reads the registry the publisher POSTs into
981
+ // and /inner/ingest validates against the tokens the spawn path minted.
982
+ innerLoop,
983
+ ingestTokens,
984
+ // 3d MD-G — the same gate registry the worker's outbox auto-disarms on
985
+ // detach/destroy; the HTTP server serves arm/disarm/decide + resolution.
986
+ gate,
951
987
  });
952
988
  log(`HTTP listening on http://${httpServerHandle.bindAddr}:${httpServerHandle.port}`);
953
989
  log(`Aggregate poll loop running (bootEpoch=${bootEpoch})`);
@@ -41,6 +41,21 @@ export declare class TickSkipped extends Error {
41
41
  readonly name = "TickSkipped";
42
42
  constructor(reason: string);
43
43
  }
44
+ /**
45
+ * 3c Tier-1 coarse activity snapshot for a single player — the bits the
46
+ * aggregate diffs to decide whether to emit `player.activity`. `currentTool`
47
+ * is normalized to `null` when idle/absent; the context fields are `undefined`
48
+ * when Pi can't report usage (e.g. right after compaction).
49
+ */
50
+ export interface PlayerCoarse {
51
+ currentTool: string | null;
52
+ contextTokens?: number;
53
+ contextPercent?: number;
54
+ }
55
+ /** Extract the coarse-activity tuple from a player summary, normalizing idle → null. */
56
+ export declare function coarseOf(p: PlayerSummaryV1): PlayerCoarse;
57
+ /** True when two coarse snapshots differ in any field. */
58
+ export declare function coarseChanged(a: PlayerCoarse | undefined, b: PlayerCoarse): boolean;
44
59
  /** Per-ensemble tracking state across ticks. */
45
60
  interface EnsembleTrack {
46
61
  bus: EnsembleEventBus;
@@ -56,6 +71,12 @@ interface EnsembleTrack {
56
71
  consecutiveFailures: number;
57
72
  /** Last seen player phase, keyed by playerId. */
58
73
  playerPhases: Map<string, string | undefined>;
74
+ /**
75
+ * 3c Tier-1 — last-seen coarse activity per playerId (currentTool + context
76
+ * usage). Diffed each poll to emit `player.activity` on change. Seeded on
77
+ * `player.added`, dropped on `player.removed` — lockstep with `playerPhases`.
78
+ */
79
+ playerCoarse: Map<string, PlayerCoarse>;
59
80
  /**
60
81
  * Adapter family per playerId — used to faithfully reconstruct the
61
82
  * prior `AggregateEnsembleSnapshot` view at tick boundaries (see #535).
@@ -175,7 +196,7 @@ export interface DiffEvent {
175
196
  type: import('./event-types').SseEventKind;
176
197
  payload: unknown;
177
198
  }
178
- export declare function diffEnsembleSnapshot(prev: AggregateEnsembleSnapshot | null, next: AggregateEnsembleSnapshot, track: Pick<EnsembleTrack, 'playerPhases' | 'playerAgentTypes' | 'flags' | 'schedulesHash' | 'chatIds' | 'chatIdOrder'>, capturedAt: string): DiffEvent[];
199
+ export declare function diffEnsembleSnapshot(prev: AggregateEnsembleSnapshot | null, next: AggregateEnsembleSnapshot, track: Pick<EnsembleTrack, 'playerPhases' | 'playerCoarse' | 'playerAgentTypes' | 'flags' | 'schedulesHash' | 'chatIds' | 'chatIdOrder'>, capturedAt: string): DiffEvent[];
179
200
  /**
180
201
  * Diff host profiles map → per-host events. Pure function.
181
202
  */
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AggregateRunner = exports.TickSkipped = exports.MAX_CONSECUTIVE_FAILURES = exports.AGGREGATE_LIST_DEADLINE_MS = exports.DEFAULT_POLL_INTERVAL_MS = void 0;
4
+ exports.coarseOf = coarseOf;
5
+ exports.coarseChanged = coarseChanged;
4
6
  exports.canonicalize = canonicalize;
5
7
  exports.hashOf = hashOf;
6
8
  exports.diffEnsembleSnapshot = diffEnsembleSnapshot;
@@ -84,6 +86,21 @@ class TickSkipped extends Error {
84
86
  constructor(reason) { super(reason); }
85
87
  }
86
88
  exports.TickSkipped = TickSkipped;
89
+ /** Extract the coarse-activity tuple from a player summary, normalizing idle → null. */
90
+ function coarseOf(p) {
91
+ return {
92
+ currentTool: p.currentTool ?? null,
93
+ ...(p.contextTokens !== undefined ? { contextTokens: p.contextTokens } : {}),
94
+ ...(p.contextPercent !== undefined ? { contextPercent: p.contextPercent } : {}),
95
+ };
96
+ }
97
+ /** True when two coarse snapshots differ in any field. */
98
+ function coarseChanged(a, b) {
99
+ return (!a ||
100
+ a.currentTool !== b.currentTool ||
101
+ a.contextTokens !== b.contextTokens ||
102
+ a.contextPercent !== b.contextPercent);
103
+ }
87
104
  /**
88
105
  * Stable JSON canonicalization — keys sorted, no extraneous whitespace.
89
106
  * Used for SHA-256 diff suppression so reordered key emits don't
@@ -118,6 +135,9 @@ function diffEnsembleSnapshot(prev, next, track, capturedAt) {
118
135
  if (!prevPlayers.has(p.playerId)) {
119
136
  events.push({ type: 'player.added', payload: p });
120
137
  track.playerPhases.set(p.playerId, p.phase);
138
+ // 3c — seed coarse so the first real change (not the add itself, whose
139
+ // payload already carries currentTool/context) emits player.activity.
140
+ track.playerCoarse.set(p.playerId, coarseOf(p));
121
141
  // #535 — record the adapter family so the prior reconstruction at
122
142
  // the next tick (aggregate.ts ~L600) carries the real agentType
123
143
  // instead of a hardcoded `'claude'` stand-in. Treated as immutable
@@ -140,6 +160,25 @@ function diffEnsembleSnapshot(prev, next, track, capturedAt) {
140
160
  });
141
161
  track.playerPhases.set(p.playerId, p.phase);
142
162
  }
163
+ // player.activity (3c Tier-1) — emit when coarse currentTool/context
164
+ // changes between polls. Distinct from phase_changed (phase vs activity).
165
+ // Source freshness ~30s (heartbeat metadata piggyback); the live, fine
166
+ // tail is the off-wire /inner side-channel.
167
+ const nextCoarse = coarseOf(p);
168
+ if (coarseChanged(track.playerCoarse.get(p.playerId), nextCoarse)) {
169
+ events.push({
170
+ type: 'player.activity',
171
+ payload: {
172
+ playerId: p.playerId,
173
+ ensemble,
174
+ currentTool: nextCoarse.currentTool,
175
+ ...(nextCoarse.contextTokens !== undefined ? { contextTokens: nextCoarse.contextTokens } : {}),
176
+ ...(nextCoarse.contextPercent !== undefined ? { contextPercent: nextCoarse.contextPercent } : {}),
177
+ at: capturedAt,
178
+ },
179
+ });
180
+ track.playerCoarse.set(p.playerId, nextCoarse);
181
+ }
143
182
  }
144
183
  // player.removed — iterate prev.
145
184
  if (prev) {
@@ -159,6 +198,7 @@ function diffEnsembleSnapshot(prev, next, track, capturedAt) {
159
198
  },
160
199
  });
161
200
  track.playerPhases.delete(p.playerId);
201
+ track.playerCoarse.delete(p.playerId);
162
202
  track.playerAgentTypes.delete(p.playerId);
163
203
  }
164
204
  }
@@ -377,6 +417,7 @@ class AggregateRunner {
377
417
  bus,
378
418
  consecutiveFailures: 0,
379
419
  playerPhases: new Map(),
420
+ playerCoarse: new Map(),
380
421
  playerAgentTypes: new Map(),
381
422
  flags: null,
382
423
  schedulesHash: null,
@@ -51,17 +51,103 @@ export declare function extractBearerToken(authHeader: string | undefined): stri
51
51
  */
52
52
  export declare function tokensMatch(received: string, expected: string): boolean;
53
53
  /**
54
- * Load (or auto-generate) the daemon's HTTP bearer token.
55
- *
56
- * - When `bearerRequired` is true and the persisted config has no
57
- * `httpToken`, generate one (`crypto.randomBytes(32).toString('base64url')`)
58
- * and persist it via `saveConfigFile` (which sets 0600 on POSIX).
59
- * - When `bearerRequired` is false, return whatever is in the config
60
- * without generating — operators may still want a token saved for
61
- * future use, and we shouldn't write secrets the user didn't request.
54
+ * @deprecated 3e superseded by {@link loadRbacTokens} (read + admin split).
55
+ * Kept only until every caller migrates; do NOT add new callers. Resolves the
56
+ * legacy single `httpToken` (env-less) for back-compat shims.
62
57
  */
63
58
  export declare function loadOrGenerateHttpToken(opts: {
64
59
  bearerRequired: boolean;
65
60
  load?: () => PersistedConfig;
66
61
  save?: (cfg: PersistedConfig) => void;
67
62
  }): string | null;
63
+ /** Resolved RBAC tokens for the daemon HTTP surface (3e). */
64
+ export interface RbacTokens {
65
+ /** T1 read token (env > config.json > auto-gen), or `null` when none + not required. */
66
+ readToken: string | null;
67
+ /** T1+T2+T3 admin token — ENV-VAR-ONLY, or `null` when unset (→ 503 on T≥2). */
68
+ adminToken: string | null;
69
+ /**
70
+ * True when a LEGACY `httpToken` (no `readToken`) was adopted as the read token.
71
+ * The daemon emits a one-time startup warning so the operator sets an admin token.
72
+ */
73
+ legacyMigrated: boolean;
74
+ }
75
+ /** Admin token — ENV-VAR-ONLY (never config.json/disk, never auto-generated). */
76
+ export declare function loadAdminToken(env?: NodeJS.ProcessEnv): string | null;
77
+ /**
78
+ * Read token (T1). Priority: env `AGENT_TEMPO_HTTP_READ_TOKEN` > config.json
79
+ * `readToken` > LEGACY config.json `httpToken` (adopted → `legacy:true`) >
80
+ * auto-generate when bearer mode is required (persisted as `readToken`).
81
+ */
82
+ export declare function loadReadToken(opts: {
83
+ bearerRequired: boolean;
84
+ env?: NodeJS.ProcessEnv;
85
+ load?: () => PersistedConfig;
86
+ save?: (cfg: PersistedConfig) => void;
87
+ }): {
88
+ token: string | null;
89
+ legacy: boolean;
90
+ };
91
+ /** Load both RBAC tokens. The daemon calls this once at startup. */
92
+ export declare function loadRbacTokens(opts: {
93
+ bearerRequired: boolean;
94
+ env?: NodeJS.ProcessEnv;
95
+ load?: () => PersistedConfig;
96
+ save?: (cfg: PersistedConfig) => void;
97
+ }): RbacTokens;
98
+ /** Access tiers (MD-E): 1 = read/observe, 2 = write/mutate, 3 = supervisory (gate/inner). */
99
+ export type Tier = 1 | 2 | 3;
100
+ /**
101
+ * Granted tier for a presented bearer, given the RBAC tokens (timing-safe).
102
+ * Admin grants the FULL ladder (3 ⊇ 2 ⊇ 1); read grants T1 only; no match → 0.
103
+ * There is no T2-only token — T2 and T3 are both "admin required".
104
+ */
105
+ export declare function tierForToken(presented: string, tokens: {
106
+ readToken: string | null;
107
+ adminToken: string | null;
108
+ }): 0 | 1 | 3;
109
+ export interface TierGuardInput {
110
+ /** Daemon bind address — decides loopback trust. */
111
+ bindAddr: string;
112
+ /** Request `Origin` header (may be absent for a non-browser client). */
113
+ originHeader: string | undefined;
114
+ /** Request `Authorization` header. */
115
+ authHeader: string | undefined;
116
+ /** Read-tier token, or `null`. */
117
+ readToken: string | null;
118
+ /** Admin token, or `null` when unset (→ 503 on T≥2). */
119
+ adminToken: string | null;
120
+ }
121
+ export type TierGuardResult = {
122
+ ok: true;
123
+ } | {
124
+ ok: false;
125
+ status: 401;
126
+ error: 'unauthorized';
127
+ } | {
128
+ ok: false;
129
+ status: 403;
130
+ error: 'insufficient-tier';
131
+ detail: string;
132
+ } | {
133
+ ok: false;
134
+ status: 503;
135
+ error: 'admin-token-not-configured';
136
+ detail: string;
137
+ };
138
+ /**
139
+ * Authorization guard (3e MD-E). Assumes the shared upstream pass already settled
140
+ * AUTHENTICATION + CORS + the DNS-rebind/Origin defense (architect's Layer 2);
141
+ * this is Layer-3 authZ — it ONLY decides tier ≥ N and emits 503/403/401.
142
+ *
143
+ * Matrix (bearer mode):
144
+ * - loopback (`!bearerRequired`) → PASS all tiers (local-trust short-circuit).
145
+ * - N ≥ 2 AND adminToken unset → 503 admin-token-not-configured.
146
+ * - no/invalid bearer (granted 0) → 401 unauthorized.
147
+ * - granted < N (read token on T≥2) → 403 insufficient-tier (+ migration hint).
148
+ * - granted ≥ N (admin, or read on T1) → PASS.
149
+ *
150
+ * Keyed off the `Authorization` bearer only (no `Origin`/cookie requirement) so a
151
+ * headless Node client passes once its token validates. View-agnostic by design.
152
+ */
153
+ export declare function requireTier(tier: Tier, input: TierGuardInput): TierGuardResult;
package/dist/http/auth.js CHANGED
@@ -39,6 +39,11 @@ exports.bearerRequired = bearerRequired;
39
39
  exports.extractBearerToken = extractBearerToken;
40
40
  exports.tokensMatch = tokensMatch;
41
41
  exports.loadOrGenerateHttpToken = loadOrGenerateHttpToken;
42
+ exports.loadAdminToken = loadAdminToken;
43
+ exports.loadReadToken = loadReadToken;
44
+ exports.loadRbacTokens = loadRbacTokens;
45
+ exports.tierForToken = tierForToken;
46
+ exports.requireTier = requireTier;
42
47
  /**
43
48
  * Authentication for the daemon HTTP surface (SSE-PROTOCOL.md §3).
44
49
  *
@@ -152,14 +157,9 @@ function tokensMatch(received, expected) {
152
157
  return crypto.timingSafeEqual(a, b);
153
158
  }
154
159
  /**
155
- * Load (or auto-generate) the daemon's HTTP bearer token.
156
- *
157
- * - When `bearerRequired` is true and the persisted config has no
158
- * `httpToken`, generate one (`crypto.randomBytes(32).toString('base64url')`)
159
- * and persist it via `saveConfigFile` (which sets 0600 on POSIX).
160
- * - When `bearerRequired` is false, return whatever is in the config
161
- * without generating — operators may still want a token saved for
162
- * future use, and we shouldn't write secrets the user didn't request.
160
+ * @deprecated 3e superseded by {@link loadRbacTokens} (read + admin split).
161
+ * Kept only until every caller migrates; do NOT add new callers. Resolves the
162
+ * legacy single `httpToken` (env-less) for back-compat shims.
163
163
  */
164
164
  function loadOrGenerateHttpToken(opts) {
165
165
  const load = opts.load ?? config_1.loadConfigFile;
@@ -170,8 +170,92 @@ function loadOrGenerateHttpToken(opts) {
170
170
  }
171
171
  if (!opts.bearerRequired)
172
172
  return null;
173
- // Auto-generate. base64url chars are safe inside Authorization values.
174
173
  const token = crypto.randomBytes(32).toString('base64url');
175
174
  save({ ...cfg, httpToken: token });
176
175
  return token;
177
176
  }
177
+ /** Admin token — ENV-VAR-ONLY (never config.json/disk, never auto-generated). */
178
+ function loadAdminToken(env = process.env) {
179
+ const t = env[config_1.ENV.HTTP_ADMIN_TOKEN];
180
+ return t && t.length > 0 ? t : null;
181
+ }
182
+ /**
183
+ * Read token (T1). Priority: env `AGENT_TEMPO_HTTP_READ_TOKEN` > config.json
184
+ * `readToken` > LEGACY config.json `httpToken` (adopted → `legacy:true`) >
185
+ * auto-generate when bearer mode is required (persisted as `readToken`).
186
+ */
187
+ function loadReadToken(opts) {
188
+ const env = opts.env ?? process.env;
189
+ const load = opts.load ?? config_1.loadConfigFile;
190
+ const save = opts.save ?? config_1.saveConfigFile;
191
+ const envTok = env[config_1.ENV.HTTP_READ_TOKEN];
192
+ if (envTok && envTok.length > 0)
193
+ return { token: envTok, legacy: false };
194
+ const cfg = load();
195
+ if (cfg.readToken && cfg.readToken.length > 0)
196
+ return { token: cfg.readToken, legacy: false };
197
+ // LEGACY: a pre-3e single `httpToken` becomes the READ token (T1) — NOT admin.
198
+ if (cfg.httpToken && cfg.httpToken.length > 0)
199
+ return { token: cfg.httpToken, legacy: true };
200
+ if (!opts.bearerRequired)
201
+ return { token: null, legacy: false };
202
+ const token = crypto.randomBytes(32).toString('base64url');
203
+ save({ ...cfg, readToken: token });
204
+ return { token, legacy: false };
205
+ }
206
+ /** Load both RBAC tokens. The daemon calls this once at startup. */
207
+ function loadRbacTokens(opts) {
208
+ const { token: readToken, legacy } = loadReadToken(opts);
209
+ const adminToken = loadAdminToken(opts.env);
210
+ return { readToken, adminToken, legacyMigrated: legacy };
211
+ }
212
+ /**
213
+ * Granted tier for a presented bearer, given the RBAC tokens (timing-safe).
214
+ * Admin grants the FULL ladder (3 ⊇ 2 ⊇ 1); read grants T1 only; no match → 0.
215
+ * There is no T2-only token — T2 and T3 are both "admin required".
216
+ */
217
+ function tierForToken(presented, tokens) {
218
+ if (tokens.adminToken && tokensMatch(presented, tokens.adminToken))
219
+ return 3;
220
+ if (tokens.readToken && tokensMatch(presented, tokens.readToken))
221
+ return 1;
222
+ return 0;
223
+ }
224
+ /** Migration hint surfaced in the 403 body so a read-token holder knows what's missing. */
225
+ const INSUFFICIENT_TIER_HINT = 'This token is read-tier. Writes, the operator gate, and the inner-tail require the admin token (set AGENT_TEMPO_HTTP_ADMIN_TOKEN).';
226
+ const ADMIN_UNSET_HINT = 'Set AGENT_TEMPO_HTTP_ADMIN_TOKEN (env-var only) to enable writes / gate / inner-tail.';
227
+ /**
228
+ * Authorization guard (3e MD-E). Assumes the shared upstream pass already settled
229
+ * AUTHENTICATION + CORS + the DNS-rebind/Origin defense (architect's Layer 2);
230
+ * this is Layer-3 authZ — it ONLY decides tier ≥ N and emits 503/403/401.
231
+ *
232
+ * Matrix (bearer mode):
233
+ * - loopback (`!bearerRequired`) → PASS all tiers (local-trust short-circuit).
234
+ * - N ≥ 2 AND adminToken unset → 503 admin-token-not-configured.
235
+ * - no/invalid bearer (granted 0) → 401 unauthorized.
236
+ * - granted < N (read token on T≥2) → 403 insufficient-tier (+ migration hint).
237
+ * - granted ≥ N (admin, or read on T1) → PASS.
238
+ *
239
+ * Keyed off the `Authorization` bearer only (no `Origin`/cookie requirement) so a
240
+ * headless Node client passes once its token validates. View-agnostic by design.
241
+ */
242
+ function requireTier(tier, input) {
243
+ // Loopback trust: local clients (TUI / CLI / future Pi widget) get full access.
244
+ if (!bearerRequired(input.bindAddr, input.originHeader))
245
+ return { ok: true };
246
+ // A tier that needs admin is UNAVAILABLE when no admin token is configured —
247
+ // the honest answer is 503, regardless of what the caller presents.
248
+ if (tier >= 2 && input.adminToken === null) {
249
+ return { ok: false, status: 503, error: 'admin-token-not-configured', detail: ADMIN_UNSET_HINT };
250
+ }
251
+ const provided = extractBearerToken(input.authHeader);
252
+ if (!provided)
253
+ return { ok: false, status: 401, error: 'unauthorized' };
254
+ const granted = tierForToken(provided, input);
255
+ if (granted === 0)
256
+ return { ok: false, status: 401, error: 'unauthorized' };
257
+ if (granted < tier) {
258
+ return { ok: false, status: 403, error: 'insufficient-tier', detail: INSUFFICIENT_TIER_HINT };
259
+ }
260
+ return { ok: true };
261
+ }
@@ -13,6 +13,9 @@ import type { IncomingMessage, ServerResponse } from 'http';
13
13
  import { type AgentType } from '../types';
14
14
  /** Hard cap on incoming JSON body size (1 MiB). */
15
15
  export declare const WRITE_BODY_MAX: number;
16
+ /** 3c Tier-2 ingest cap (32 KiB) — the DOS backstop for `/inner/ingest`; the
17
+ * source already ~2KB-truncates summaries, so real frames are far smaller. */
18
+ export declare const INGEST_BODY_MAX: number;
16
19
  export declare const BODY_TOO_LARGE: unique symbol;
17
20
  export declare const BODY_INVALID_JSON: unique symbol;
18
21
  export type ReadJsonBodyResult = Record<string, unknown> | typeof BODY_TOO_LARGE | typeof BODY_INVALID_JSON;
@@ -26,7 +29,7 @@ export type ReadJsonBodyResult = Record<string, unknown> | typeof BODY_TOO_LARGE
26
29
  * handler ends. Explicit `req.destroy()` would race the response
27
30
  * write — left alone.
28
31
  */
29
- export declare function readJsonBody(req: IncomingMessage): Promise<ReadJsonBodyResult>;
32
+ export declare function readJsonBody(req: IncomingMessage, maxBytes?: number): Promise<ReadJsonBodyResult>;
30
33
  /**
31
34
  * Pluck a string field from a parsed JSON body. Returns `undefined`
32
35
  * for absent or non-string values; with `requireNonEmpty: true`, also