@app-vitals/agent-runtime 0.0.1 → 0.1.1

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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,398 @@
1
+ /**
2
+ * CronRunReporter — reports cron run outcomes to the admin API.
3
+ *
4
+ * Two-step interface:
5
+ * 1. createRun() — POST at run start, returns runId (or null on error)
6
+ * 2. completeRun() / skipRun() — PATCH at run completion
7
+ *
8
+ * HttpCronRunReporter: production implementation, fire-and-forget.
9
+ * NoopCronRunReporter: testing / default when not configured.
10
+ */
11
+
12
+ export interface ModelBreakdownEntry {
13
+ model: string;
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ cacheReadTokens: number;
17
+ cacheCreationTokens: number;
18
+ costUsd: number;
19
+ }
20
+
21
+ /**
22
+ * The first assistant turn's usage — the measured always-loaded context for
23
+ * this run (system prompt + tool schemas + CLAUDE.md/rules + skill listing +
24
+ * cron prompt), independent of cache warmth. See agent/src/runtime/run-telemetry.ts.
25
+ */
26
+ export interface ContextBaselineEntry {
27
+ model: string;
28
+ contextTokens: number;
29
+ inputTokens: number;
30
+ cacheCreationTokens: number;
31
+ cacheReadTokens: number;
32
+ }
33
+
34
+ /** Per-skill / per-subagent token attribution row. See agent/src/runtime/run-telemetry.ts. */
35
+ export interface SkillUsageEntry {
36
+ kind: "skill" | "agent" | "root";
37
+ name: string;
38
+ invocations: number;
39
+ turns: number;
40
+ inputTokens: number;
41
+ outputTokens: number;
42
+ cacheReadTokens: number;
43
+ cacheCreationTokens: number;
44
+ invokeContextDelta: number | null;
45
+ }
46
+
47
+ /** Optional error/token fields shared by completeRun() and skipRun()'s opts. */
48
+ export interface RunReportOpts {
49
+ error?: string;
50
+ inputTokens?: number;
51
+ outputTokens?: number;
52
+ cacheReadTokens?: number;
53
+ cacheCreationTokens?: number;
54
+ modelBreakdown?: ModelBreakdownEntry[];
55
+ sessionId?: string;
56
+ contextBaseline?: ContextBaselineEntry;
57
+ turns?: number;
58
+ toolCalls?: number;
59
+ skillUsage?: SkillUsageEntry[];
60
+ /** sha256[:12] of the workspace's always-loaded markdown + plugin version. */
61
+ contextFingerprint?: string;
62
+ pluginVersion?: string;
63
+ claudeCodeVersion?: string;
64
+ }
65
+
66
+ export interface CronRunReporter {
67
+ /**
68
+ * Called at run start — returns the runId to use for completion (null for
69
+ * no-op).
70
+ *
71
+ * `phaseId` tags the run with the dispatched phase's child AgentCronJob id
72
+ * (dev-task / review / patch / deploy). Under the single-cron loop model
73
+ * every invocation shares one cronId, so phaseId is the only way to
74
+ * attribute a run to a specific command in the admin UI and metrics
75
+ * dashboard. Optional and additive — callers that don't pass it (e.g. the
76
+ * generic cron handler) keep working unchanged.
77
+ *
78
+ * `itemType`/`itemId` tag the run with the work item ("task" | "pr") the
79
+ * dispatched command was invoked against (e.g. itemType "task", itemId
80
+ * "WLS-2.2", or itemType "pr", itemId "acme/x#123"). Optional and additive,
81
+ * same as `phaseId` — a tick with no dispatch (skipped tick, empty queue)
82
+ * passes neither.
83
+ */
84
+ createRun(
85
+ cronId: string,
86
+ startedAt: Date,
87
+ phaseId?: string,
88
+ itemType?: string,
89
+ itemId?: string,
90
+ commandVariant?: string,
91
+ ): Promise<string | null>;
92
+ /** Called when run completes (success or error). Includes token data for non-error. */
93
+ completeRun(
94
+ cronId: string,
95
+ runId: string | null,
96
+ completedAt: Date,
97
+ outcome: "completed" | "failed",
98
+ opts?: RunReportOpts,
99
+ phaseId?: string,
100
+ itemType?: string,
101
+ itemId?: string,
102
+ ): Promise<void>;
103
+ /**
104
+ * Called when a dispatch is recorded as skipped. Most skip paths (409
105
+ * conflict, pre-claim throw, redispatch cooldown) never run a command, so
106
+ * opts is usually just `{ error? }`. The `[silent]`-marker path is the
107
+ * exception: runner(message) already ran and may have spent real tokens
108
+ * before reporting nothing-to-do, so opts also accepts the same token
109
+ * fields as completeRun() — passed via buildTokenPayload(runResult.usage,
110
+ * runResult.modelUsage) at that call site — so that spend isn't dropped.
111
+ */
112
+ skipRun(
113
+ cronId: string,
114
+ runId: string | null,
115
+ completedAt: Date,
116
+ skipReason: string,
117
+ opts?: RunReportOpts,
118
+ phaseId?: string,
119
+ itemType?: string,
120
+ itemId?: string,
121
+ ): Promise<void>;
122
+ /**
123
+ * Fire-and-forget mid-run progress push. PATCHes modelBreakdown and
124
+ * lastHeartbeatAt — no completedAt/outcome, so it never signals run
125
+ * completion. Safe to call repeatedly; the admin PATCH handler upserts
126
+ * modelBreakdown per [cronRunId, model] and overwrites lastHeartbeatAt.
127
+ *
128
+ * `lastHeartbeatAt` must be sourced from the caller's injected Clock
129
+ * (`clock.now()`), never `new Date()`/`Date.now()` directly, so it stays
130
+ * deterministic under a FixedClock in tests (CRH-1.1).
131
+ */
132
+ recordProgress(
133
+ cronId: string,
134
+ runId: string | null,
135
+ modelBreakdown: ModelBreakdownEntry[],
136
+ lastHeartbeatAt: Date,
137
+ ): Promise<void>;
138
+ /**
139
+ * Fire-and-forget mid-run session-id push. PATCHes only sessionId — no
140
+ * completedAt/outcome, so it never signals run completion. Called as soon as
141
+ * an early session id is known (before the run finishes), so the cron-run
142
+ * log has it for manual inspection/resume even if the run never reaches a
143
+ * terminal state.
144
+ */
145
+ recordSessionId(
146
+ cronId: string,
147
+ runId: string | null,
148
+ sessionId: string,
149
+ ): Promise<void>;
150
+ }
151
+
152
+ interface HttpCronRunReporterOptions {
153
+ apiUrl: string;
154
+ agentId: string;
155
+ apiKey: string;
156
+ }
157
+
158
+ /** Applies RunReportOpts' fields onto a PATCH body, skipping unset ones. */
159
+ function applyRunReportOpts(
160
+ body: Record<string, unknown>,
161
+ opts?: RunReportOpts,
162
+ ): void {
163
+ if (opts?.error !== undefined) body.error = opts.error;
164
+ if (opts?.inputTokens !== undefined) body.inputTokens = opts.inputTokens;
165
+ if (opts?.outputTokens !== undefined) body.outputTokens = opts.outputTokens;
166
+ if (opts?.cacheReadTokens !== undefined)
167
+ body.cacheReadTokens = opts.cacheReadTokens;
168
+ if (opts?.cacheCreationTokens !== undefined)
169
+ body.cacheCreationTokens = opts.cacheCreationTokens;
170
+ if (opts?.modelBreakdown !== undefined)
171
+ body.modelBreakdown = opts.modelBreakdown;
172
+ if (opts?.sessionId !== undefined) body.sessionId = opts.sessionId;
173
+ if (opts?.contextBaseline !== undefined)
174
+ body.contextBaseline = opts.contextBaseline;
175
+ if (opts?.turns !== undefined) body.turns = opts.turns;
176
+ if (opts?.toolCalls !== undefined) body.toolCalls = opts.toolCalls;
177
+ if (opts?.skillUsage !== undefined) body.skillUsage = opts.skillUsage;
178
+ if (opts?.contextFingerprint !== undefined)
179
+ body.contextFingerprint = opts.contextFingerprint;
180
+ if (opts?.pluginVersion !== undefined)
181
+ body.pluginVersion = opts.pluginVersion;
182
+ if (opts?.claudeCodeVersion !== undefined)
183
+ body.claudeCodeVersion = opts.claudeCodeVersion;
184
+ }
185
+
186
+ export class HttpCronRunReporter implements CronRunReporter {
187
+ constructor(private opts: HttpCronRunReporterOptions) {}
188
+
189
+ private async patchRun(
190
+ url: string,
191
+ body: Record<string, unknown>,
192
+ ): Promise<void> {
193
+ const { apiKey } = this.opts;
194
+ try {
195
+ const res = await fetch(url, {
196
+ method: "PATCH",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ Authorization: `Bearer ${apiKey}`,
200
+ },
201
+ body: JSON.stringify(body),
202
+ });
203
+ if (!res.ok) {
204
+ console.warn(
205
+ `[cron-run-reporter] PATCH ${url} returned ${res.status} — swallowing`,
206
+ );
207
+ }
208
+ } catch (err) {
209
+ console.warn(
210
+ `[cron-run-reporter] PATCH ${url} failed: ${String(err)} — swallowing`,
211
+ );
212
+ }
213
+ }
214
+
215
+ async createRun(
216
+ cronId: string,
217
+ startedAt: Date,
218
+ phaseId?: string,
219
+ itemType?: string,
220
+ itemId?: string,
221
+ commandVariant?: string,
222
+ ): Promise<string | null> {
223
+ const { apiUrl, agentId, apiKey } = this.opts;
224
+ const url = `${apiUrl}/agents/${agentId}/crons/${cronId}/runs`;
225
+
226
+ const startBody: Record<string, unknown> = {
227
+ startedAt: startedAt.toISOString(),
228
+ };
229
+ if (phaseId !== undefined) startBody.phaseId = phaseId;
230
+ if (itemType !== undefined) startBody.itemType = itemType;
231
+ if (itemId !== undefined) startBody.itemId = itemId;
232
+ if (commandVariant !== undefined) startBody.commandVariant = commandVariant;
233
+
234
+ try {
235
+ const res = await fetch(url, {
236
+ method: "POST",
237
+ headers: {
238
+ "Content-Type": "application/json",
239
+ Authorization: `Bearer ${apiKey}`,
240
+ },
241
+ body: JSON.stringify(startBody),
242
+ });
243
+ if (!res.ok) {
244
+ console.warn(
245
+ `[cron-run-reporter] POST ${url} returned ${res.status} — swallowing`,
246
+ );
247
+ return null;
248
+ }
249
+ const data = (await res.json()) as { run: { id: string } };
250
+ return data.run.id;
251
+ } catch (err) {
252
+ console.warn(
253
+ `[cron-run-reporter] failed to create run for cron ${cronId}: ${String(err)} — swallowing`,
254
+ );
255
+ return null;
256
+ }
257
+ }
258
+
259
+ async completeRun(
260
+ cronId: string,
261
+ runId: string | null,
262
+ completedAt: Date,
263
+ outcome: "completed" | "failed",
264
+ opts?: RunReportOpts,
265
+ phaseId?: string,
266
+ itemType?: string,
267
+ itemId?: string,
268
+ ): Promise<void> {
269
+ if (runId === null) return;
270
+
271
+ const { apiUrl, agentId } = this.opts;
272
+ const url = `${apiUrl}/agents/${agentId}/crons/${cronId}/runs/${runId}`;
273
+
274
+ const body: Record<string, unknown> = {
275
+ completedAt: completedAt.toISOString(),
276
+ outcome,
277
+ };
278
+ applyRunReportOpts(body, opts);
279
+ if (phaseId !== undefined) body.phaseId = phaseId;
280
+ if (itemType !== undefined) body.itemType = itemType;
281
+ if (itemId !== undefined) body.itemId = itemId;
282
+
283
+ await this.patchRun(url, body);
284
+ }
285
+
286
+ async skipRun(
287
+ cronId: string,
288
+ runId: string | null,
289
+ completedAt: Date,
290
+ skipReason: string,
291
+ opts?: RunReportOpts,
292
+ phaseId?: string,
293
+ itemType?: string,
294
+ itemId?: string,
295
+ ): Promise<void> {
296
+ if (runId === null) return;
297
+
298
+ const { apiUrl, agentId } = this.opts;
299
+ const url = `${apiUrl}/agents/${agentId}/crons/${cronId}/runs/${runId}`;
300
+
301
+ const body: Record<string, unknown> = {
302
+ completedAt: completedAt.toISOString(),
303
+ skipped: true,
304
+ skipReason,
305
+ };
306
+ applyRunReportOpts(body, opts);
307
+ if (phaseId !== undefined) body.phaseId = phaseId;
308
+ if (itemType !== undefined) body.itemType = itemType;
309
+ if (itemId !== undefined) body.itemId = itemId;
310
+
311
+ await this.patchRun(url, body);
312
+ }
313
+
314
+ async recordProgress(
315
+ cronId: string,
316
+ runId: string | null,
317
+ modelBreakdown: ModelBreakdownEntry[],
318
+ lastHeartbeatAt: Date,
319
+ ): Promise<void> {
320
+ if (runId === null) return;
321
+
322
+ const { apiUrl, agentId } = this.opts;
323
+ const url = `${apiUrl}/agents/${agentId}/crons/${cronId}/runs/${runId}`;
324
+
325
+ await this.patchRun(url, {
326
+ modelBreakdown,
327
+ lastHeartbeatAt: lastHeartbeatAt.toISOString(),
328
+ });
329
+ }
330
+
331
+ async recordSessionId(
332
+ cronId: string,
333
+ runId: string | null,
334
+ sessionId: string,
335
+ ): Promise<void> {
336
+ if (runId === null) return;
337
+
338
+ const { apiUrl, agentId } = this.opts;
339
+ const url = `${apiUrl}/agents/${agentId}/crons/${cronId}/runs/${runId}`;
340
+
341
+ await this.patchRun(url, { sessionId });
342
+ }
343
+ }
344
+
345
+ export class NoopCronRunReporter implements CronRunReporter {
346
+ async createRun(
347
+ _cronId: string,
348
+ _startedAt: Date,
349
+ _phaseId?: string,
350
+ _itemType?: string,
351
+ _itemId?: string,
352
+ ): Promise<string | null> {
353
+ return null;
354
+ }
355
+
356
+ async completeRun(
357
+ _cronId: string,
358
+ _runId: string | null,
359
+ _completedAt: Date,
360
+ _outcome: "completed" | "failed",
361
+ _opts?: RunReportOpts,
362
+ _phaseId?: string,
363
+ _itemType?: string,
364
+ _itemId?: string,
365
+ ): Promise<void> {
366
+ // intentional no-op
367
+ }
368
+
369
+ async skipRun(
370
+ _cronId: string,
371
+ _runId: string | null,
372
+ _completedAt: Date,
373
+ _skipReason: string,
374
+ _opts?: RunReportOpts,
375
+ _phaseId?: string,
376
+ _itemType?: string,
377
+ _itemId?: string,
378
+ ): Promise<void> {
379
+ // intentional no-op
380
+ }
381
+
382
+ async recordProgress(
383
+ _cronId: string,
384
+ _runId: string | null,
385
+ _modelBreakdown: ModelBreakdownEntry[],
386
+ _lastHeartbeatAt: Date,
387
+ ): Promise<void> {
388
+ // intentional no-op
389
+ }
390
+
391
+ async recordSessionId(
392
+ _cronId: string,
393
+ _runId: string | null,
394
+ _sessionId: string,
395
+ ): Promise<void> {
396
+ // intentional no-op
397
+ }
398
+ }
package/crypto.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * agent/src/runtime/crypto.ts
3
+ * AES-256-GCM encryption for at-rest value storage.
4
+ *
5
+ * Encrypted format: `iv:ciphertext:authTag` — all hex-encoded, colon-separated.
6
+ *
7
+ * Key must be a 32-byte value expressed as a 64-character hex string
8
+ * (e.g. SHIPWRIGHT_ENCRYPTION_KEY env var).
9
+ */
10
+
11
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
12
+
13
+ const ALGORITHM = "aes-256-gcm";
14
+ const IV_BYTES = 12; // 96-bit IV — recommended for GCM
15
+
16
+ /**
17
+ * Encrypts a plaintext string with AES-256-GCM.
18
+ *
19
+ * @param plaintext - The string to encrypt.
20
+ * @param keyHex - 64-char hex string representing a 32-byte key.
21
+ * @returns Hex-encoded `iv:ciphertext:authTag`.
22
+ */
23
+ export function encrypt(plaintext: string, keyHex: string): string {
24
+ const key = Buffer.from(keyHex, "hex");
25
+ const iv = randomBytes(IV_BYTES);
26
+ const cipher = createCipheriv(ALGORITHM, key, iv);
27
+
28
+ const ciphertext = Buffer.concat([
29
+ cipher.update(plaintext, "utf8"),
30
+ cipher.final(),
31
+ ]);
32
+
33
+ const authTag = cipher.getAuthTag();
34
+
35
+ return [
36
+ iv.toString("hex"),
37
+ ciphertext.toString("hex"),
38
+ authTag.toString("hex"),
39
+ ].join(":");
40
+ }
41
+
42
+ /**
43
+ * Decrypts a value produced by `encrypt()`.
44
+ *
45
+ * @param encrypted - Hex-encoded `iv:ciphertext:authTag` string.
46
+ * @param keyHex - 64-char hex string representing a 32-byte key.
47
+ * @returns The original plaintext string.
48
+ * @throws If the format is invalid, the key is wrong, or the auth tag fails.
49
+ */
50
+ export function decrypt(encrypted: string, keyHex: string): string {
51
+ const parts = encrypted.split(":");
52
+ if (parts.length !== 3) {
53
+ throw new Error(
54
+ `Invalid encrypted format: expected "iv:ciphertext:authTag", got ${parts.length} part(s)`,
55
+ );
56
+ }
57
+
58
+ const [ivHex, ciphertextHex, authTagHex] = parts as [string, string, string];
59
+ const key = Buffer.from(keyHex, "hex");
60
+ const iv = Buffer.from(ivHex, "hex");
61
+ const ciphertext = Buffer.from(ciphertextHex, "hex");
62
+ const authTag = Buffer.from(authTagHex, "hex");
63
+
64
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
65
+ decipher.setAuthTag(authTag);
66
+
67
+ const decrypted = Buffer.concat([
68
+ decipher.update(ciphertext),
69
+ decipher.final(),
70
+ ]);
71
+
72
+ return decrypted.toString("utf8");
73
+ }