@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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 (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Attachment safety bounds mirrored from DevSpec core validation.
3
+ * Keep these values aligned with packages/core/src/validation/attachments.ts.
4
+ */
5
+ export const MAX_ATTACHMENTS = 10;
6
+ export const MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
7
+
8
+ export const ATTACHMENT_TYPES = ["text", "pdf", "image", "document"] as const;
9
+ export type AttachmentType = (typeof ATTACHMENT_TYPES)[number];
10
+
11
+ export const MAX_ATTACHMENT_BYTES: Readonly<Record<AttachmentType, number>> = Object.freeze({
12
+ text: 2 * 1024 * 1024,
13
+ pdf: 12 * 1024 * 1024,
14
+ image: 5 * 1024 * 1024,
15
+ document: 10 * 1024 * 1024,
16
+ });
17
+
18
+ export function isAttachmentType(value: unknown): value is AttachmentType {
19
+ return typeof value === "string" && (ATTACHMENT_TYPES as readonly string[]).includes(value);
20
+ }
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Materialise owner-command attachments to local 0600 files before the model
3
+ * sees the turn. Signed URLs are bearer credentials: fetch now, never retain,
4
+ * log, or place them in model-visible failure text.
5
+ */
6
+
7
+ import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
8
+ import { basename, dirname, join } from "node:path";
9
+ import {
10
+ MAX_ATTACHMENTS,
11
+ MAX_ATTACHMENT_BYTES,
12
+ MAX_TOTAL_ATTACHMENT_BYTES,
13
+ isAttachmentType,
14
+ } from "./attachment-limits.ts";
15
+ import { unwrapMcpResult } from "./result.ts";
16
+ import { devspecDir } from "./config.ts";
17
+
18
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
19
+ const INITIAL_DOWNLOAD_BUFFER_BYTES = 64 * 1024;
20
+ const MAX_DOWNLOAD_CHUNKS = 65_536;
21
+ const MAX_ZERO_PROGRESS_CHUNKS = 16;
22
+
23
+ export type AttachmentDelivery = "file" | "unavailable";
24
+
25
+ export interface RawAttachment {
26
+ filename?: unknown;
27
+ mimeType?: unknown;
28
+ mime_type?: unknown;
29
+ type?: unknown;
30
+ sizeBytes?: unknown;
31
+ size_bytes?: unknown;
32
+ resource_id?: unknown;
33
+ materialization?: unknown;
34
+ content?: unknown;
35
+ dataUrl?: unknown;
36
+ payload?: unknown;
37
+ reason?: unknown;
38
+ }
39
+
40
+ export interface MaterialisedAttachment {
41
+ filename: string;
42
+ mimeType: string;
43
+ type: string;
44
+ sizeBytes: number | null;
45
+ delivery: AttachmentDelivery;
46
+ path?: string;
47
+ note: string;
48
+ }
49
+
50
+ export interface AttachmentHydrationBudget {
51
+ remainingBytes: number;
52
+ }
53
+
54
+ export interface MaterialiseDeps {
55
+ token: string;
56
+ connectionId: string;
57
+ dir?: string;
58
+ fetchUrl?: (url: string, maxBytes: number) => Promise<Uint8Array>;
59
+ writeFile?: (target: string, bytes: Uint8Array) => Promise<void>;
60
+ removeFile?: (target: string) => Promise<void>;
61
+ getResource?: (token: string, args: Record<string, unknown>) => Promise<unknown>;
62
+ }
63
+
64
+ class SafeAttachmentError extends Error {
65
+ constructor(message: string) {
66
+ super(message);
67
+ this.name = "SafeAttachmentError";
68
+ }
69
+ }
70
+
71
+ export function sanitizeAttachmentFailureReason(value: unknown, fallback = "attachment operation failed"): string {
72
+ if (value instanceof SafeAttachmentError) return value.message;
73
+ const raw = typeof value === "string" ? value : value instanceof Error ? value.message : "";
74
+ const sanitized = raw
75
+ .replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]+/g, "[redacted URL]")
76
+ .replace(/\b(token|signature|sig|credential|authorization|key)=\S+/gi, "$1=[REDACTED]")
77
+ .replace(/[\x00-\x1F\x7F]/g, " ")
78
+ .replace(/\s+/g, " ")
79
+ .trim()
80
+ .slice(0, 160);
81
+ return sanitized || fallback;
82
+ }
83
+
84
+ function safeExternalFailure(error: unknown, fallback: string): string {
85
+ return error instanceof SafeAttachmentError ? error.message : fallback;
86
+ }
87
+
88
+ export function isMaterialisedAttachment(a: unknown): a is MaterialisedAttachment {
89
+ if (!a || typeof a !== "object") return false;
90
+ const d = (a as { delivery?: unknown }).delivery;
91
+ return d === "file" || d === "unavailable";
92
+ }
93
+
94
+ export function attachmentDirFor(connectionId: string): string {
95
+ const safe = connectionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "connection";
96
+ return join(devspecDir(), "attachments", safe);
97
+ }
98
+
99
+ export function safeAttachmentName(filename: unknown): string {
100
+ const base = basename(typeof filename === "string" && filename.trim() ? filename : "attachment");
101
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "");
102
+ return cleaned.slice(0, 120) || "attachment";
103
+ }
104
+
105
+ function metaOf(a: RawAttachment): {
106
+ filename: string;
107
+ mimeType: string;
108
+ type: string;
109
+ sizeBytes: number | null;
110
+ } {
111
+ const mimeType = typeof a.mime_type === "string" ? a.mime_type : a.mimeType;
112
+ const sizeBytes = typeof a.size_bytes === "number" ? a.size_bytes : a.sizeBytes;
113
+ return {
114
+ filename: safeAttachmentName(a.filename),
115
+ mimeType: typeof mimeType === "string" && mimeType.trim() ? mimeType : "application/octet-stream",
116
+ type: typeof a.type === "string" && a.type.trim() ? a.type : "document",
117
+ sizeBytes: typeof sizeBytes === "number" && Number.isFinite(sizeBytes) ? sizeBytes : null,
118
+ };
119
+ }
120
+
121
+ function bytesFromLegacy(a: RawAttachment, maxBytes: number): Uint8Array | null {
122
+ let b64 = typeof a.content === "string" && a.content.length > 0 ? a.content : null;
123
+ if (!b64 && typeof a.dataUrl === "string" && a.dataUrl.startsWith("data:")) {
124
+ const comma = a.dataUrl.indexOf(",");
125
+ if (comma >= 0) {
126
+ const payload = a.dataUrl.slice(comma + 1).trim();
127
+ if (payload) b64 = payload;
128
+ }
129
+ }
130
+ if (!b64) return null;
131
+ if (b64.length > Math.ceil(maxBytes / 3) * 4 + 4) throw new SafeAttachmentError("attachment bytes exceed safe bound");
132
+ const bytes = Buffer.from(b64, "base64");
133
+ if (bytes.byteLength > maxBytes) throw new SafeAttachmentError("attachment bytes exceed safe bound");
134
+ return bytes;
135
+ }
136
+
137
+ interface ResourceFile {
138
+ url: string;
139
+ filename: string | null;
140
+ mimeType: string | null;
141
+ sizeBytes: number | null;
142
+ }
143
+
144
+ function downloadFileFromResource(details: unknown): ResourceFile | null {
145
+ if (!details || typeof details !== "object") return null;
146
+ const file = (details as { file?: unknown }).file;
147
+ if (!file || typeof file !== "object") return null;
148
+ const row = file as Record<string, unknown>;
149
+ const url = row.download_url;
150
+ if (typeof url !== "string" || !url.startsWith("https://")) return null;
151
+ const rawSize = row.size_bytes;
152
+ if (rawSize !== undefined && rawSize !== null && (typeof rawSize !== "number" || !Number.isInteger(rawSize) || rawSize < 0)) {
153
+ throw new SafeAttachmentError("resource returned invalid attachment size metadata");
154
+ }
155
+ return {
156
+ url,
157
+ filename: typeof row.filename === "string" && row.filename.length > 0 ? row.filename : null,
158
+ mimeType: typeof row.mime_type === "string" && row.mime_type.length > 0 ? row.mime_type : null,
159
+ sizeBytes: typeof rawSize === "number" ? rawSize : null,
160
+ };
161
+ }
162
+
163
+ function assertResourceIdentity(a: RawAttachment, resourceId: string, details: unknown, file: ResourceFile): void {
164
+ const root = details as { id?: unknown } | null;
165
+ if (typeof root?.id === "string" && root.id !== resourceId) throw new SafeAttachmentError("resource identity did not match attachment metadata");
166
+ if (typeof a.filename === "string" && a.filename.length > 0 && file.filename !== null && file.filename !== a.filename) {
167
+ throw new SafeAttachmentError("resource filename did not match attachment metadata");
168
+ }
169
+ const declaredMime = typeof a.mime_type === "string" ? a.mime_type : a.mimeType;
170
+ if (typeof declaredMime === "string" && declaredMime.length > 0 && file.mimeType !== null && file.mimeType !== declaredMime) {
171
+ throw new SafeAttachmentError("resource MIME type did not match attachment metadata");
172
+ }
173
+ const declaredSize = metaOf(a).sizeBytes;
174
+ if (declaredSize !== null && file.sizeBytes !== null && file.sizeBytes !== declaredSize) {
175
+ throw new SafeAttachmentError("resource size did not match attachment metadata");
176
+ }
177
+ }
178
+
179
+ function assertDownloadedSize(declaredSize: number | null, resourceSize: number | null, actualSize: number): void {
180
+ if (declaredSize !== null && actualSize !== declaredSize) throw new SafeAttachmentError("downloaded size did not match attachment metadata");
181
+ if (resourceSize !== null && actualSize !== resourceSize) throw new SafeAttachmentError("downloaded size did not match resource metadata");
182
+ }
183
+
184
+ export async function defaultWriteBytes(target: string, bytes: Uint8Array): Promise<void> {
185
+ const dir = dirname(target);
186
+ await mkdir(dir, { recursive: true, mode: 0o700 });
187
+ await chmod(dir, 0o700);
188
+ await writeFile(target, bytes, { mode: 0o600 });
189
+ await chmod(target, 0o600);
190
+ }
191
+
192
+ export async function defaultFetchUrl(
193
+ url: string,
194
+ maxBytes: number,
195
+ timeoutMs = DOWNLOAD_TIMEOUT_MS,
196
+ ): Promise<Uint8Array> {
197
+ const controller = new AbortController();
198
+ let rejectTimeout!: (error: Error) => void;
199
+ const timeout = new Promise<never>((_resolve, reject) => { rejectTimeout = reject; });
200
+ const timer = setTimeout(() => {
201
+ controller.abort();
202
+ rejectTimeout(new SafeAttachmentError("attachment download timed out"));
203
+ }, timeoutMs);
204
+ if (typeof timer.unref === "function") timer.unref();
205
+
206
+ let response: Response | undefined;
207
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
208
+ const cancel = () => {
209
+ controller.abort();
210
+ if (reader) void reader.cancel().catch(() => {});
211
+ else if (response?.body) void response.body.cancel().catch(() => {});
212
+ };
213
+ try {
214
+ response = await Promise.race([fetch(url, { signal: controller.signal }), timeout]);
215
+ if (!response.ok) {
216
+ cancel();
217
+ throw new SafeAttachmentError(`download failed (${response.status})`);
218
+ }
219
+ const rawLength = response.headers.get("content-length");
220
+ const contentLength = rawLength && /^\d+$/.test(rawLength) ? Number(rawLength) : null;
221
+ if (contentLength !== null && contentLength > maxBytes) {
222
+ cancel();
223
+ throw new SafeAttachmentError("download exceeds attachment byte limit");
224
+ }
225
+ if (!response.body) {
226
+ if (contentLength !== null && contentLength !== 0) throw new SafeAttachmentError("downloaded size did not match Content-Length");
227
+ return new Uint8Array();
228
+ }
229
+
230
+ reader = response.body.getReader();
231
+ let capacity = Math.min(maxBytes, Math.max(contentLength ?? 0, Math.min(INITIAL_DOWNLOAD_BUFFER_BYTES, maxBytes)));
232
+ let bytes = new Uint8Array(capacity);
233
+ let total = 0;
234
+ let chunks = 0;
235
+ let zeroProgress = 0;
236
+ while (true) {
237
+ const { done, value } = await Promise.race([reader.read(), timeout]);
238
+ if (done) break;
239
+ chunks += 1;
240
+ if (chunks > MAX_DOWNLOAD_CHUNKS) throw new SafeAttachmentError("attachment download was excessively fragmented");
241
+ if (value.byteLength === 0) {
242
+ zeroProgress += 1;
243
+ if (zeroProgress > MAX_ZERO_PROGRESS_CHUNKS) throw new SafeAttachmentError("attachment download made no progress");
244
+ continue;
245
+ }
246
+ zeroProgress = 0;
247
+ const needed = total + value.byteLength;
248
+ if (needed > maxBytes) throw new SafeAttachmentError("download exceeds attachment byte limit");
249
+ if (needed > capacity) {
250
+ capacity = Math.min(maxBytes, Math.max(needed, Math.max(INITIAL_DOWNLOAD_BUFFER_BYTES, capacity * 2)));
251
+ const grown = new Uint8Array(capacity);
252
+ grown.set(bytes.subarray(0, total));
253
+ bytes = grown;
254
+ }
255
+ bytes.set(value, total);
256
+ total = needed;
257
+ }
258
+ if (contentLength !== null && total !== contentLength) throw new SafeAttachmentError("downloaded size did not match Content-Length");
259
+ return bytes.slice(0, total);
260
+ } catch (error) {
261
+ cancel();
262
+ throw error;
263
+ } finally {
264
+ clearTimeout(timer);
265
+ try { reader?.releaseLock(); } catch { /* cancellation may already release it */ }
266
+ }
267
+ }
268
+
269
+ async function removeBytes(deps: MaterialiseDeps, target: string): Promise<void> {
270
+ if (deps.removeFile) await deps.removeFile(target);
271
+ else await rm(target, { force: true });
272
+ }
273
+
274
+ async function writeBytes(
275
+ deps: MaterialiseDeps,
276
+ messageId: string | undefined,
277
+ index: number,
278
+ filename: string,
279
+ bytes: Uint8Array,
280
+ ): Promise<string> {
281
+ const dir = deps.dir ?? attachmentDirFor(deps.connectionId);
282
+ const safeMessageId = String(messageId ?? "msg").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "msg";
283
+ const target = join(dir, `${safeMessageId}-${index}-${filename}`);
284
+ const write = deps.writeFile ?? defaultWriteBytes;
285
+ try {
286
+ await write(target, bytes);
287
+ return target;
288
+ } catch (error) {
289
+ await removeBytes(deps, target).catch(() => {});
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ function unavailable(meta: ReturnType<typeof metaOf>, reason: unknown, fallback = "unavailable"): MaterialisedAttachment {
295
+ return {
296
+ ...meta,
297
+ delivery: "unavailable",
298
+ note: `${meta.filename} was attached and could not be opened (${sanitizeAttachmentFailureReason(reason, fallback)}).`,
299
+ };
300
+ }
301
+
302
+ function fileNote(type: string): string {
303
+ return type === "image"
304
+ ? "Image saved locally — Read this path to see it. It is part of the command, not decoration."
305
+ : "Saved locally — Read this path if the command refers to it.";
306
+ }
307
+
308
+ interface HydratedResult {
309
+ attachment: MaterialisedAttachment | null;
310
+ actualBytes: number;
311
+ }
312
+
313
+ export function createAttachmentHydrationBudget(lists: readonly (readonly unknown[])[]): AttachmentHydrationBudget {
314
+ let count = 0;
315
+ for (const list of lists) {
316
+ count += list.length;
317
+ if (count > MAX_ATTACHMENTS) throw new SafeAttachmentError("attachment count exceeds safe bound across command bundle");
318
+ }
319
+ let declaredTotal = 0;
320
+ let index = 0;
321
+ for (const list of lists) {
322
+ for (const raw of list) {
323
+ index += 1;
324
+ if (!raw || typeof raw !== "object") throw new SafeAttachmentError(`attachment ${index} has invalid metadata`);
325
+ const a = raw as RawAttachment;
326
+ const meta = metaOf(a);
327
+ const declared = a.size_bytes ?? a.sizeBytes;
328
+ if (declared !== undefined && declared !== null) {
329
+ if (typeof declared !== "number" || !Number.isInteger(declared) || declared < 0) {
330
+ throw new SafeAttachmentError(`attachment ${index} has invalid declared size`);
331
+ }
332
+ if (isAttachmentType(meta.type) && declared > MAX_ATTACHMENT_BYTES[meta.type]) {
333
+ throw new SafeAttachmentError(`attachment ${index} declared size exceeds safe bound`);
334
+ }
335
+ declaredTotal += declared;
336
+ if (declaredTotal > MAX_TOTAL_ATTACHMENT_BYTES) throw new SafeAttachmentError("declared attachment total exceeds safe bound across command bundle");
337
+ }
338
+ }
339
+ }
340
+ return { remainingBytes: MAX_TOTAL_ATTACHMENT_BYTES };
341
+ }
342
+
343
+ async function hydrateAttachment(
344
+ raw: unknown,
345
+ deps: MaterialiseDeps,
346
+ opts: { messageId?: string; index: number; budget: AttachmentHydrationBudget },
347
+ ): Promise<HydratedResult> {
348
+ if (!raw || typeof raw !== "object") return { attachment: null, actualBytes: 0 };
349
+ if (isMaterialisedAttachment(raw)) return { attachment: raw, actualBytes: raw.sizeBytes ?? 0 };
350
+
351
+ const a = raw as RawAttachment;
352
+ const meta = metaOf(a);
353
+ const typeLimit = isAttachmentType(meta.type) ? MAX_ATTACHMENT_BYTES[meta.type] : MAX_TOTAL_ATTACHMENT_BYTES;
354
+ const maxBytes = Math.min(typeLimit, opts.budget.remainingBytes);
355
+
356
+ if (a.materialization === "unavailable" || a.payload === "unavailable") {
357
+ return { attachment: unavailable(meta, a.reason, "unavailable"), actualBytes: 0 };
358
+ }
359
+
360
+ const resourceId = typeof a.resource_id === "string" && a.resource_id.length > 0 ? a.resource_id : null;
361
+ if (resourceId) {
362
+ try {
363
+ if (!deps.getResource) return { attachment: unavailable(meta, "no fetch helper"), actualBytes: 0 };
364
+ const rawResult = await deps.getResource(deps.token, { id: resourceId, connection_id: deps.connectionId });
365
+ const { details, isError } = unwrapMcpResult(rawResult);
366
+ if (isError) return { attachment: unavailable(meta, "resource fetch failed"), actualBytes: 0 };
367
+ const file = downloadFileFromResource(details);
368
+ if (!file) return { attachment: unavailable(meta, "no signed download"), actualBytes: 0 };
369
+ assertResourceIdentity(a, resourceId, details, file);
370
+ const bytes = await (deps.fetchUrl ?? defaultFetchUrl)(file.url, maxBytes);
371
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength > maxBytes) throw new SafeAttachmentError("download exceeds attachment byte limit");
372
+ assertDownloadedSize(meta.sizeBytes, file.sizeBytes, bytes.byteLength);
373
+ const path = await writeBytes(deps, opts.messageId, opts.index, meta.filename, bytes);
374
+ return { attachment: { ...meta, delivery: "file", path, note: fileNote(meta.type) }, actualBytes: bytes.byteLength };
375
+ } catch (error) {
376
+ return { attachment: unavailable(meta, safeExternalFailure(error, "attachment download failed")), actualBytes: 0 };
377
+ }
378
+ }
379
+
380
+ try {
381
+ const leftover = bytesFromLegacy(a, maxBytes);
382
+ if (leftover) {
383
+ assertDownloadedSize(meta.sizeBytes, null, leftover.byteLength);
384
+ const path = await writeBytes(deps, opts.messageId, opts.index, meta.filename, leftover);
385
+ return { attachment: { ...meta, delivery: "file", path, note: fileNote(meta.type) }, actualBytes: leftover.byteLength };
386
+ }
387
+ } catch (error) {
388
+ return { attachment: unavailable(meta, safeExternalFailure(error, "attachment hydration failed")), actualBytes: 0 };
389
+ }
390
+ return { attachment: unavailable(meta, "missing_resource"), actualBytes: 0 };
391
+ }
392
+
393
+ export async function materialiseAttachment(
394
+ raw: unknown,
395
+ deps: MaterialiseDeps,
396
+ opts: { messageId?: string; index: number },
397
+ ): Promise<MaterialisedAttachment | null> {
398
+ const budget = createAttachmentHydrationBudget([[raw]]);
399
+ return (await hydrateAttachment(raw, deps, { ...opts, budget })).attachment;
400
+ }
401
+
402
+ export async function cleanupMaterialisedAttachments(
403
+ attachments: readonly MaterialisedAttachment[],
404
+ removeFile: (target: string) => Promise<void> = (target) => rm(target, { force: true }),
405
+ ): Promise<void> {
406
+ await Promise.allSettled(
407
+ attachments
408
+ .filter((attachment) => attachment.delivery === "file" && attachment.path)
409
+ .map((attachment) => removeFile(attachment.path!)),
410
+ );
411
+ }
412
+
413
+ export class AttachmentDeliveryLease {
414
+ private accepted = false;
415
+ private readonly attachments: readonly MaterialisedAttachment[];
416
+ private readonly removeFile?: (target: string) => Promise<void>;
417
+ constructor(
418
+ attachments: readonly MaterialisedAttachment[],
419
+ removeFile?: (target: string) => Promise<void>,
420
+ ) {
421
+ this.attachments = attachments;
422
+ this.removeFile = removeFile;
423
+ }
424
+ accept(): void { this.accepted = true; }
425
+ async cleanupIfPending(): Promise<void> {
426
+ if (!this.accepted) await cleanupMaterialisedAttachments(this.attachments, this.removeFile);
427
+ }
428
+ }
429
+
430
+ export async function materialiseAttachments(
431
+ list: unknown,
432
+ deps: MaterialiseDeps,
433
+ messageId?: string,
434
+ budget?: AttachmentHydrationBudget,
435
+ ): Promise<MaterialisedAttachment[]> {
436
+ if (!Array.isArray(list) || list.length === 0) return [];
437
+ const activeBudget = budget ?? createAttachmentHydrationBudget([list]);
438
+ const out: MaterialisedAttachment[] = [];
439
+ for (let i = 0; i < list.length; i++) {
440
+ const result = await hydrateAttachment(list[i], deps, { messageId, index: i, budget: activeBudget });
441
+ if (result.attachment) out.push(result.attachment);
442
+ if (!result.attachment || result.attachment.delivery !== "file") {
443
+ await cleanupMaterialisedAttachments(out, (target) => removeBytes(deps, target));
444
+ return out;
445
+ }
446
+ activeBudget.remainingBytes -= result.actualBytes;
447
+ }
448
+ return out;
449
+ }
450
+
451
+ export function requireMaterialisedAttachments(
452
+ attachments: readonly MaterialisedAttachment[],
453
+ expectedCount = attachments.length,
454
+ ): void {
455
+ if (attachments.length !== expectedCount) throw new Error("attachment hydration was incomplete");
456
+ const failed = attachments.find((attachment) => attachment.delivery !== "file" || !attachment.path);
457
+ if (failed) throw new Error(`attachment hydration failed: ${failed.filename}`);
458
+ }
459
+
460
+ /** Prompt text: paths and reasons only. Never bytes, never a URL. */
461
+ export function formatAttachmentNotes(attachments: readonly MaterialisedAttachment[]): string {
462
+ if (attachments.length === 0) return "";
463
+ const lines = ["Attached files (open the local paths — they are part of the command):"];
464
+ for (const a of attachments) {
465
+ const size = a.sizeBytes != null ? `, ${a.sizeBytes} bytes` : "";
466
+ if (a.delivery === "file" && a.path) {
467
+ lines.push(`- ${a.filename} (${a.mimeType}${size}) → ${a.path}`);
468
+ lines.push(` ${a.note}`);
469
+ } else {
470
+ lines.push(`- ${a.filename} (${a.mimeType}${size})`);
471
+ lines.push(` ${a.note}`);
472
+ }
473
+ }
474
+ return lines.join("\n");
475
+ }
476
+
477
+ export function parseRawAttachments(value: unknown): RawAttachment[] {
478
+ if (!Array.isArray(value)) return [];
479
+ return value.filter((a): a is RawAttachment => !!a && typeof a === "object");
480
+ }
481
+
482
+ export function hasLegacyInlineBytes(a: RawAttachment): boolean {
483
+ return (
484
+ (typeof a.content === "string" && a.content.length > 0) ||
485
+ (typeof a.dataUrl === "string" && a.dataUrl.startsWith("data:"))
486
+ );
487
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * DevSpec Pi — token store.
3
+ *
4
+ * Tokens live at `<pi-agent-dir>/devspec/auth.json` with 0600 perms.
5
+ * Resolution order (headless-friendly):
6
+ * 1. DEVSPEC_MCP_TOKEN env var (CI, cloud workers, RPC hosts)
7
+ * 2. the on-disk store written by /devspec.login
8
+ *
9
+ * Pure node, no pi imports — unit-testable under `node --test`.
10
+ */
11
+
12
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { devspecDir, DEFAULT_MCP_URL, ENV_ENV_VAR, STAGING_MCP_URL, TOKEN_ENV_VAR, URL_ENV_VAR } from "./config.ts";
15
+
16
+ export interface StoredAuth {
17
+ token: string;
18
+ /** Endpoint this token was verified against (staging vs prod) — tokens are per-environment. */
19
+ url?: string;
20
+ /** Token scope as reported by DevSpec ("read_only" | "read_write"), if known. */
21
+ scope?: string;
22
+ /** Display name of the principal the token acts as, from verify_agent_connection. */
23
+ principal?: string;
24
+ /** ISO timestamp of the last successful verification. */
25
+ verifiedAt?: string;
26
+ }
27
+
28
+ export interface ResolvedToken {
29
+ token: string;
30
+ source: "env" | "store";
31
+ }
32
+
33
+ export function authFilePath(): string {
34
+ return join(devspecDir(), "auth.json");
35
+ }
36
+
37
+ export async function loadStoredAuth(): Promise<StoredAuth | null> {
38
+ try {
39
+ const raw = await readFile(authFilePath(), "utf8");
40
+ const parsed: unknown = JSON.parse(raw);
41
+ if (
42
+ parsed !== null &&
43
+ typeof parsed === "object" &&
44
+ typeof (parsed as { token?: unknown }).token === "string" &&
45
+ (parsed as { token: string }).token.length > 0
46
+ ) {
47
+ return parsed as StoredAuth;
48
+ }
49
+ return null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function saveAuth(auth: StoredAuth): Promise<void> {
56
+ await mkdir(devspecDir(), { recursive: true, mode: 0o700 });
57
+ await writeFile(authFilePath(), `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
58
+ // Belt and braces: umask may widen perms on some platforms.
59
+ await chmod(authFilePath(), 0o600).catch(() => {});
60
+ }
61
+
62
+ export async function clearAuth(): Promise<void> {
63
+ await rm(authFilePath(), { force: true });
64
+ }
65
+
66
+ /**
67
+ * Resolve the effective token. Env always wins so headless runs
68
+ * never depend on interactive login state.
69
+ */
70
+ export async function resolveToken(): Promise<ResolvedToken | null> {
71
+ const env = process.env[TOKEN_ENV_VAR];
72
+ if (env && env.trim()) return { token: env.trim(), source: "env" };
73
+ const stored = await loadStoredAuth();
74
+ if (stored) return { token: stored.token, source: "store" };
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Resolve the effective endpoint: URL override → staging shorthand → the
80
+ * endpoint captured at login (tokens are per-environment, so the store's URL
81
+ * keeps a staging login working without env vars) → production default.
82
+ */
83
+ export async function resolveEndpoint(): Promise<string> {
84
+ const override = process.env[URL_ENV_VAR];
85
+ if (override && override.trim()) return override.trim();
86
+ const env = process.env[ENV_ENV_VAR];
87
+ if (env && env.trim().toLowerCase() === "staging") return STAGING_MCP_URL;
88
+ const stored = await loadStoredAuth();
89
+ if (stored?.url) return stored.url;
90
+ return DEFAULT_MCP_URL;
91
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Revalidate an automation-run retry against the server-owned exact-target inbox.
3
+ * A captured envelope explains what failed; only the current server read grants
4
+ * authority after the transport cursor advances.
5
+ */
6
+ export interface AutomationRunRetryInbox<T> {
7
+ dispatches: readonly T[];
8
+ /** True only for a server-confirmed exact identity read. */
9
+ identityComplete: boolean;
10
+ }
11
+
12
+ export async function revalidateAutomationRunRetry<T>(input: {
13
+ key: string;
14
+ loadCurrentInbox: () => Promise<AutomationRunRetryInbox<T>>;
15
+ identity: (value: T) => string | undefined;
16
+ assertCurrent?: () => void;
17
+ withdrawCurrent?: () => void;
18
+ enqueueCurrent: (value: T) => void;
19
+ }): Promise<"enqueued" | "withdrawn" | "retry"> {
20
+ const inbox = await input.loadCurrentInbox();
21
+ input.assertCurrent?.();
22
+ const current = inbox.dispatches.find((value) => input.identity(value) === input.key);
23
+ if (!current) {
24
+ if (!inbox.identityComplete) return "retry";
25
+ input.withdrawCurrent?.();
26
+ return "withdrawn";
27
+ }
28
+ input.assertCurrent?.();
29
+ input.enqueueCurrent(current);
30
+ return "enqueued";
31
+ }