@awak-app/simy-cli 0.2.2 → 0.3.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.
@@ -0,0 +1,266 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ import {
4
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY,
5
+ LocalTaskFileCapabilityError,
6
+ validateLocalTaskFile,
7
+ validateLocalTaskFileCollection,
8
+ } from "./local-task-file-capabilities.js";
9
+
10
+ export const MAX_LOCAL_TASK_ARTIFACT_REFS =
11
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.output.max_files;
12
+ export const MAX_LOCAL_TASK_ARTIFACT_MANIFEST_BYTES =
13
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.output.max_manifest_bytes;
14
+ export const MAX_LOCAL_TASK_ARTIFACT_ID_CHARS = 256;
15
+ export const MAX_LOCAL_TASK_ARTIFACT_NAME_CHARS = 512;
16
+ export const MAX_LOCAL_TASK_ARTIFACT_PATH_CHARS = 2_048;
17
+ export const MAX_LOCAL_TASK_ARTIFACT_MIME_CHARS = 256;
18
+
19
+ const ARTIFACT_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
20
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
21
+ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/;
22
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
23
+ const ALLOWED_ARTIFACT_REF_FIELDS = new Set([
24
+ "id",
25
+ "local_task_id",
26
+ "name",
27
+ "relative_path",
28
+ "mime_type",
29
+ "size_bytes",
30
+ "sha256",
31
+ "verified",
32
+ ]);
33
+
34
+ export class LocalTaskArtifactContractError extends Error {
35
+ constructor(message, {
36
+ field = null,
37
+ index = null,
38
+ code = "local_task_artifact_contract_invalid",
39
+ recoveryAction =
40
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.choose_supported_file,
41
+ } = {}) {
42
+ super(message);
43
+ this.name = "LocalTaskArtifactContractError";
44
+ this.code = code;
45
+ this.recovery_action = recoveryAction;
46
+ this.recovery = recoveryAction.id;
47
+ this.field = field;
48
+ this.index = index;
49
+ }
50
+ }
51
+
52
+ export function buildLocalTaskArtifactRefs(artifacts, localTaskId) {
53
+ const refs = Array.isArray(artifacts)
54
+ ? artifacts.map((artifact) => ({
55
+ id: artifact?.id,
56
+ local_task_id: localTaskId,
57
+ name: artifact?.name,
58
+ relative_path: artifact?.relative_path,
59
+ mime_type: artifact?.mime_type,
60
+ size_bytes: artifact?.size_bytes,
61
+ sha256: artifact?.sha256,
62
+ verified: artifact?.verified === true,
63
+ }))
64
+ : artifacts;
65
+ return validateLocalTaskArtifactRefs(refs, { expectedLocalTaskId: localTaskId });
66
+ }
67
+
68
+ export function validateLocalTaskArtifactRefs(
69
+ refs,
70
+ { expectedLocalTaskId = null } = {},
71
+ ) {
72
+ if (!Array.isArray(refs)) {
73
+ throw contractError("Local Task output metadata must be a list.");
74
+ }
75
+ if (refs.length > MAX_LOCAL_TASK_ARTIFACT_REFS) {
76
+ throw contractError(
77
+ `Local Task produced ${refs.length} output files; SIMY supports at most ${MAX_LOCAL_TASK_ARTIFACT_REFS}.`,
78
+ );
79
+ }
80
+
81
+ const validatedFiles = refs.map((artifact, index) =>
82
+ validateArtifactRef(artifact, index, expectedLocalTaskId));
83
+ try {
84
+ validateLocalTaskFileCollection(validatedFiles, { direction: "output" });
85
+ } catch (error) {
86
+ throw capabilityContractError(error);
87
+ }
88
+
89
+ const canonicalRefs = refs.map((artifact, index) =>
90
+ canonicalArtifactRef({ ...artifact, mime_type: validatedFiles[index].mime_type }));
91
+ const serialized = serializeCanonicalRefs(canonicalRefs);
92
+ const manifestBytes = Buffer.byteLength(serialized, "utf8");
93
+ if (manifestBytes > MAX_LOCAL_TASK_ARTIFACT_MANIFEST_BYTES) {
94
+ throw contractError(
95
+ `Local Task output metadata exceeds the ${MAX_LOCAL_TASK_ARTIFACT_MANIFEST_BYTES}-byte safety limit.`,
96
+ );
97
+ }
98
+ return canonicalRefs;
99
+ }
100
+
101
+ export function localTaskArtifactManifestBytes(refs) {
102
+ return Buffer.byteLength(
103
+ serializeCanonicalRefs(
104
+ Array.isArray(refs) ? refs.map(canonicalArtifactRef) : refs,
105
+ ),
106
+ "utf8",
107
+ );
108
+ }
109
+
110
+ export function serializeLocalTaskArtifactManifest(refs) {
111
+ return serializeCanonicalRefs(
112
+ Array.isArray(refs) ? refs.map(canonicalArtifactRef) : refs,
113
+ );
114
+ }
115
+
116
+ function validateArtifactRef(artifact, index, expectedLocalTaskId) {
117
+ if (!isRecord(artifact)) {
118
+ throw artifactError(index, "must be an object");
119
+ }
120
+ if (Object.keys(artifact).some((key) => !ALLOWED_ARTIFACT_REF_FIELDS.has(key))) {
121
+ throw artifactError(index, "contains unsupported metadata");
122
+ }
123
+
124
+ requireCanonicalString(artifact.id, {
125
+ index,
126
+ field: "id",
127
+ label: "identifier",
128
+ maxChars: MAX_LOCAL_TASK_ARTIFACT_ID_CHARS,
129
+ pattern: ARTIFACT_ID_PATTERN,
130
+ });
131
+ requireCanonicalString(artifact.local_task_id, {
132
+ index,
133
+ field: "local_task_id",
134
+ label: "Local Task identifier",
135
+ maxChars: MAX_LOCAL_TASK_ARTIFACT_ID_CHARS,
136
+ pattern: ARTIFACT_ID_PATTERN,
137
+ });
138
+ if (expectedLocalTaskId !== null && artifact.local_task_id !== expectedLocalTaskId) {
139
+ throw artifactError(index, "does not belong to this Local Task", "local_task_id");
140
+ }
141
+
142
+ requireCanonicalString(artifact.name, {
143
+ index,
144
+ field: "name",
145
+ label: "name",
146
+ maxChars: MAX_LOCAL_TASK_ARTIFACT_NAME_CHARS,
147
+ rejectControls: true,
148
+ });
149
+ if (/[\\/]/.test(artifact.name)) {
150
+ throw artifactError(index, "has a name containing a path separator", "name");
151
+ }
152
+
153
+ requireCanonicalString(artifact.relative_path, {
154
+ index,
155
+ field: "relative_path",
156
+ label: "relative path",
157
+ maxChars: MAX_LOCAL_TASK_ARTIFACT_PATH_CHARS,
158
+ rejectControls: true,
159
+ });
160
+ if (!isSafeRelativeArtifactPath(artifact.relative_path)) {
161
+ throw artifactError(index, "does not have a safe relative path", "relative_path");
162
+ }
163
+
164
+ requireCanonicalString(artifact.mime_type, {
165
+ index,
166
+ field: "mime_type",
167
+ label: "MIME type",
168
+ maxChars: MAX_LOCAL_TASK_ARTIFACT_MIME_CHARS,
169
+ });
170
+ if (typeof artifact.sha256 !== "string" || !SHA256_PATTERN.test(artifact.sha256)) {
171
+ throw artifactError(index, "has an invalid SHA-256 digest", "sha256");
172
+ }
173
+ if (artifact.verified !== true) {
174
+ throw artifactError(index, "was not verified", "verified");
175
+ }
176
+ try {
177
+ return validateLocalTaskFile({
178
+ name: artifact.name,
179
+ mime_type: artifact.mime_type,
180
+ size_bytes: artifact.size_bytes,
181
+ sha256: artifact.sha256,
182
+ }, { direction: "output" });
183
+ } catch (error) {
184
+ throw capabilityContractError(error, index);
185
+ }
186
+ }
187
+
188
+ function requireCanonicalString(
189
+ value,
190
+ { index, field, label, maxChars, pattern = null, rejectControls = false },
191
+ ) {
192
+ if (typeof value !== "string" || !value || value.trim() !== value) {
193
+ throw artifactError(index, `has an invalid ${label}`, field);
194
+ }
195
+ if (characterCount(value) > maxChars) {
196
+ throw artifactError(index, `has a ${label} longer than ${maxChars} characters`, field);
197
+ }
198
+ if (rejectControls && CONTROL_CHARACTER_PATTERN.test(value)) {
199
+ throw artifactError(index, `has a ${label} containing control characters`, field);
200
+ }
201
+ if (pattern && !pattern.test(value)) {
202
+ throw artifactError(index, `has an invalid ${label}`, field);
203
+ }
204
+ }
205
+
206
+ function isSafeRelativeArtifactPath(value) {
207
+ if (
208
+ value.startsWith("/") ||
209
+ /^~(?:\/|$)/.test(value) ||
210
+ WINDOWS_DRIVE_PATH_PATTERN.test(value) ||
211
+ value.includes("\\") ||
212
+ value.endsWith("/")
213
+ ) {
214
+ return false;
215
+ }
216
+ return value
217
+ .split("/")
218
+ .every((segment) => segment !== "" && segment !== "." && segment !== "..");
219
+ }
220
+
221
+ function characterCount(value) {
222
+ return value.length;
223
+ }
224
+
225
+ function canonicalArtifactRef(artifact) {
226
+ return {
227
+ id: artifact?.id,
228
+ local_task_id: artifact?.local_task_id,
229
+ name: artifact?.name,
230
+ relative_path: artifact?.relative_path,
231
+ mime_type: artifact?.mime_type,
232
+ size_bytes: artifact?.size_bytes,
233
+ sha256: artifact?.sha256,
234
+ verified: artifact?.verified,
235
+ };
236
+ }
237
+
238
+ function serializeCanonicalRefs(refs) {
239
+ try {
240
+ return JSON.stringify(refs);
241
+ } catch {
242
+ throw contractError("Local Task output metadata is not valid JSON.");
243
+ }
244
+ }
245
+
246
+ function artifactError(index, detail, field = null) {
247
+ return contractError(`Local Task output file ${index + 1} ${detail}.`, { index, field });
248
+ }
249
+
250
+ function contractError(message, details = {}) {
251
+ return new LocalTaskArtifactContractError(message, details);
252
+ }
253
+
254
+ function capabilityContractError(error, index = null) {
255
+ if (!(error instanceof LocalTaskFileCapabilityError)) throw error;
256
+ return contractError(error.message, {
257
+ index,
258
+ field: error.field,
259
+ code: error.code,
260
+ recoveryAction: error.recovery_action,
261
+ });
262
+ }
263
+
264
+ function isRecord(value) {
265
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
266
+ }
@@ -0,0 +1,447 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ realpath,
9
+ readdir,
10
+ rm,
11
+ stat,
12
+ } from "node:fs/promises";
13
+ import path from "node:path";
14
+
15
+ import {
16
+ LOCAL_TASK_ATTACHMENT_REF_VERSION,
17
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY,
18
+ LOCAL_TASK_FILE_CAPABILITY_VERSION,
19
+ LocalTaskFileCapabilityError,
20
+ validateLocalTaskFile,
21
+ validateLocalTaskFileCollection,
22
+ } from "./local-task-file-capabilities.js";
23
+
24
+ const SAFE_IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$/;
25
+ const SAFE_DEVICE_ID = /^[A-Za-z0-9._:-]{1,256}$/;
26
+ const SAFE_STAGE_ID = /^[a-f0-9]{64}$/;
27
+ const SAFE_ATTACHMENT_ID = /^attachment_[A-Za-z0-9-]{36}$/;
28
+ const REF_FIELDS = new Set([
29
+ "schema_version",
30
+ "capability_version",
31
+ "stage_id",
32
+ "attachment_id",
33
+ "device_id",
34
+ "name",
35
+ "mime_type",
36
+ "size_bytes",
37
+ "sha256",
38
+ ]);
39
+ const STAGING_RETENTION_MS = 24 * 60 * 60 * 1_000;
40
+ const STAGING_MAX_STAGES = 20;
41
+ const STAGING_MAX_BYTES = 200 * 1024 * 1024;
42
+
43
+ export function createLocalTaskAttachmentStore({ root }) {
44
+ if (!root) throw new TypeError("root is required");
45
+ const storageRoot = path.resolve(root);
46
+ let stageQueue = Promise.resolve();
47
+ const withStageLock = async (operation) => {
48
+ const previous = stageQueue;
49
+ let release;
50
+ stageQueue = new Promise((resolve) => {
51
+ release = resolve;
52
+ });
53
+ await previous;
54
+ try {
55
+ return await operation();
56
+ } finally {
57
+ release();
58
+ }
59
+ };
60
+
61
+ return {
62
+ root: storageRoot,
63
+ async cleanupExpired({
64
+ now = Date.now(),
65
+ retentionMs = STAGING_RETENTION_MS,
66
+ } = {}) {
67
+ let entries;
68
+ try {
69
+ entries = await readdir(storageRoot, { withFileTypes: true });
70
+ } catch (error) {
71
+ if (error?.code === "ENOENT") return 0;
72
+ throw error;
73
+ }
74
+ let removed = 0;
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory() || !SAFE_STAGE_ID.test(entry.name)) continue;
77
+ const stageRoot = resolveStageRoot(storageRoot, entry.name);
78
+ const manifestPath = path.join(stageRoot, "manifest.json");
79
+ let timestamp = Number.NaN;
80
+ try {
81
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
82
+ timestamp = Date.parse(manifest.created_at);
83
+ if (!Number.isFinite(timestamp)) {
84
+ timestamp = (await stat(stageRoot)).mtimeMs;
85
+ }
86
+ } catch {
87
+ timestamp = (await stat(stageRoot)).mtimeMs;
88
+ }
89
+ if (now - timestamp <= retentionMs) continue;
90
+ await rm(stageRoot, { recursive: true, force: true });
91
+ removed += 1;
92
+ }
93
+ return removed;
94
+ },
95
+ async stage({ deviceId, idempotencyKey, files }) {
96
+ return withStageLock(async () => {
97
+ await this.cleanupExpired();
98
+ const normalizedDeviceId = requireDeviceId(deviceId);
99
+ const key = requireIdempotencyKey(idempotencyKey);
100
+ if (!Array.isArray(files) || files.length === 0) {
101
+ throw storeError(
102
+ "Choose at least one file to attach.",
103
+ "local_task_attachment_required",
104
+ "reattach_file",
105
+ );
106
+ }
107
+ const validated = validateLocalTaskFileCollection(files, {
108
+ direction: "input",
109
+ requireBytes: true,
110
+ });
111
+ const stageId = digest(`${normalizedDeviceId}\0${key}`);
112
+ const fingerprint = digest(JSON.stringify(validated.map((file) => ({
113
+ name: file.name,
114
+ mime_type: file.mime_type,
115
+ size_bytes: file.size_bytes,
116
+ sha256: file.sha256,
117
+ }))));
118
+ const stageRoot = resolveStageRoot(storageRoot, stageId);
119
+ const manifestPath = path.join(stageRoot, "manifest.json");
120
+
121
+ try {
122
+ const existing = JSON.parse(await readFile(manifestPath, "utf8"));
123
+ if (
124
+ existing.device_id !== normalizedDeviceId ||
125
+ existing.fingerprint !== fingerprint
126
+ ) {
127
+ throw storeError(
128
+ "These files differ from the first request using this idempotency key.",
129
+ "local_task_attachment_idempotency_conflict",
130
+ "reattach_file",
131
+ );
132
+ }
133
+ return { refs: existing.refs, replayed: true };
134
+ } catch (error) {
135
+ if (error instanceof LocalTaskFileCapabilityError) throw error;
136
+ if (error?.code !== "ENOENT") throw error;
137
+ }
138
+
139
+ const usage = await stagingUsage(storageRoot);
140
+ const incomingBytes = validated.reduce(
141
+ (total, file) => total + file.size_bytes,
142
+ 0,
143
+ );
144
+ if (
145
+ usage.stages >= STAGING_MAX_STAGES ||
146
+ usage.bytes + incomingBytes > STAGING_MAX_BYTES
147
+ ) {
148
+ throw storeError(
149
+ "SIMY is already holding several attached files. Finish or retry the active tasks, then attach these files again.",
150
+ "local_task_attachment_staging_quota_exceeded",
151
+ "remove_extra_files",
152
+ );
153
+ }
154
+
155
+ await mkdir(storageRoot, { recursive: true, mode: 0o700 });
156
+ await mkdir(stageRoot, { recursive: false, mode: 0o700 });
157
+ const refs = [];
158
+ const entries = [];
159
+ try {
160
+ for (const [index, file] of files.entries()) {
161
+ const normalized = validated[index];
162
+ const attachmentId = `attachment_${randomUUID()}`;
163
+ const storageName = `${attachmentId}.bin`;
164
+ const target = path.join(stageRoot, storageName);
165
+ await writeExclusive(target, Buffer.from(file.bytes));
166
+ const ref = {
167
+ schema_version: LOCAL_TASK_ATTACHMENT_REF_VERSION,
168
+ capability_version: LOCAL_TASK_FILE_CAPABILITY_VERSION,
169
+ stage_id: stageId,
170
+ attachment_id: attachmentId,
171
+ device_id: normalizedDeviceId,
172
+ name: normalized.name,
173
+ mime_type: normalized.mime_type,
174
+ size_bytes: normalized.size_bytes,
175
+ sha256: normalized.sha256,
176
+ };
177
+ refs.push(ref);
178
+ entries.push({ ...ref, storage_name: storageName });
179
+ }
180
+ await writeExclusive(manifestPath, Buffer.from(JSON.stringify({
181
+ schema_version: LOCAL_TASK_ATTACHMENT_REF_VERSION,
182
+ capability_version: LOCAL_TASK_FILE_CAPABILITY_VERSION,
183
+ stage_id: stageId,
184
+ device_id: normalizedDeviceId,
185
+ fingerprint,
186
+ refs,
187
+ entries,
188
+ created_at: new Date().toISOString(),
189
+ })));
190
+ return { refs, replayed: false };
191
+ } catch (error) {
192
+ await rm(stageRoot, { recursive: true, force: true });
193
+ throw error;
194
+ }
195
+ });
196
+ },
197
+
198
+ async materialize({ refs, deviceId, inputsRoot }) {
199
+ const normalizedDeviceId = requireDeviceId(deviceId);
200
+ const values = validateRefs(refs, normalizedDeviceId);
201
+ if (values.length === 0) return [];
202
+ const targetRoot = path.resolve(inputsRoot);
203
+ await mkdir(targetRoot, { recursive: true, mode: 0o700 });
204
+ const materialized = [];
205
+
206
+ for (const ref of values) {
207
+ const stageRoot = resolveStageRoot(storageRoot, ref.stage_id);
208
+ const manifest = await readManifest(stageRoot);
209
+ const entry = manifest.entries?.find(
210
+ (value) => value.attachment_id === ref.attachment_id,
211
+ );
212
+ if (!entry || !sameRef(entry, ref)) {
213
+ throw storeError(
214
+ `${ref.name} is no longer available on this desktop.`,
215
+ "local_task_attachment_ref_not_found",
216
+ "reattach_file",
217
+ );
218
+ }
219
+ const source = path.join(stageRoot, entry.storage_name);
220
+ await assertManagedRegularFile(source, stageRoot);
221
+ const bytes = await readFile(source);
222
+ const validated = validateLocalTaskFile({
223
+ ...ref,
224
+ bytes,
225
+ }, { direction: "input", requireBytes: true });
226
+ const localPath = path.join(
227
+ targetRoot,
228
+ `${ref.attachment_id}-${validated.name}`,
229
+ );
230
+ await writeExclusive(localPath, bytes);
231
+ materialized.push({
232
+ id: ref.attachment_id,
233
+ name: validated.name,
234
+ mime_type: validated.mime_type,
235
+ size_bytes: validated.size_bytes,
236
+ sha256: validated.sha256,
237
+ local_path: localPath,
238
+ integrity_status: "verified",
239
+ staged_at: manifest.created_at,
240
+ executor_handoff_status: "pending",
241
+ delivered_at: null,
242
+ cleanup_status: "not_required",
243
+ cleaned_at: null,
244
+ cleanup_error: null,
245
+ storage: "managed_copy",
246
+ capability_version: validated.capability_version,
247
+ });
248
+ }
249
+ return materialized;
250
+ },
251
+
252
+ async cleanup({ refs, deviceId }) {
253
+ const normalizedDeviceId = requireDeviceId(deviceId);
254
+ const values = validateRefs(refs, normalizedDeviceId);
255
+ const stageIds = new Set(values.map((ref) => ref.stage_id));
256
+ for (const stageId of stageIds) {
257
+ await rm(resolveStageRoot(storageRoot, stageId), {
258
+ recursive: true,
259
+ force: true,
260
+ });
261
+ }
262
+ },
263
+ };
264
+ }
265
+
266
+ async function stagingUsage(storageRoot) {
267
+ let entries;
268
+ try {
269
+ entries = await readdir(storageRoot, { withFileTypes: true });
270
+ } catch (error) {
271
+ if (error?.code === "ENOENT") return { stages: 0, bytes: 0 };
272
+ throw error;
273
+ }
274
+ let stages = 0;
275
+ let bytes = 0;
276
+ for (const entry of entries) {
277
+ if (!entry.isDirectory() || !SAFE_STAGE_ID.test(entry.name)) continue;
278
+ stages += 1;
279
+ try {
280
+ const manifest = JSON.parse(
281
+ await readFile(path.join(storageRoot, entry.name, "manifest.json"), "utf8"),
282
+ );
283
+ for (const ref of Array.isArray(manifest.refs) ? manifest.refs : []) {
284
+ if (Number.isSafeInteger(ref?.size_bytes) && ref.size_bytes > 0) {
285
+ bytes += ref.size_bytes;
286
+ }
287
+ }
288
+ } catch {
289
+ // A corrupt local stage still consumes a stage slot and is removed by
290
+ // normal expiry; never trust it to report a smaller byte count.
291
+ bytes = STAGING_MAX_BYTES;
292
+ }
293
+ }
294
+ return { stages, bytes };
295
+ }
296
+
297
+ function validateRefs(refs, deviceId) {
298
+ if (!Array.isArray(refs)) {
299
+ throw storeError(
300
+ "The attached-file references are invalid.",
301
+ "local_task_attachment_ref_invalid",
302
+ "reattach_file",
303
+ );
304
+ }
305
+ if (refs.length > LOCAL_TASK_FILE_CAPABILITY_REGISTRY.input.max_files) {
306
+ throw storeError(
307
+ "Too many attached-file references were provided.",
308
+ "local_task_files_count_exceeded",
309
+ "remove_extra_files",
310
+ );
311
+ }
312
+ return refs.map((ref) => {
313
+ if (
314
+ !ref ||
315
+ typeof ref !== "object" ||
316
+ Array.isArray(ref) ||
317
+ Object.keys(ref).some((key) => !REF_FIELDS.has(key)) ||
318
+ ref.schema_version !== LOCAL_TASK_ATTACHMENT_REF_VERSION ||
319
+ ref.capability_version !== LOCAL_TASK_FILE_CAPABILITY_VERSION ||
320
+ !SAFE_STAGE_ID.test(String(ref.stage_id || "")) ||
321
+ !SAFE_ATTACHMENT_ID.test(String(ref.attachment_id || "")) ||
322
+ ref.device_id !== deviceId
323
+ ) {
324
+ const versionMismatch =
325
+ ref?.schema_version !== LOCAL_TASK_ATTACHMENT_REF_VERSION ||
326
+ ref?.capability_version !== LOCAL_TASK_FILE_CAPABILITY_VERSION;
327
+ throw storeError(
328
+ versionMismatch
329
+ ? "SIMY CLI must be updated before these files can be used."
330
+ : "These files were attached from another desktop session.",
331
+ versionMismatch
332
+ ? "local_task_attachment_version_unsupported"
333
+ : "local_task_attachment_wrong_device",
334
+ versionMismatch ? "update_cli" : "reattach_file",
335
+ );
336
+ }
337
+ validateLocalTaskFile(ref, { direction: "input" });
338
+ return { ...ref };
339
+ });
340
+ }
341
+
342
+ async function readManifest(stageRoot) {
343
+ try {
344
+ const manifestPath = path.join(stageRoot, "manifest.json");
345
+ await assertManagedRegularFile(manifestPath, stageRoot);
346
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
347
+ if (
348
+ manifest.schema_version !== LOCAL_TASK_ATTACHMENT_REF_VERSION ||
349
+ manifest.capability_version !== LOCAL_TASK_FILE_CAPABILITY_VERSION
350
+ ) {
351
+ throw storeError(
352
+ "SIMY CLI must be updated before these files can be used.",
353
+ "local_task_attachment_version_unsupported",
354
+ "update_cli",
355
+ );
356
+ }
357
+ return manifest;
358
+ } catch (error) {
359
+ if (error instanceof LocalTaskFileCapabilityError) throw error;
360
+ throw storeError(
361
+ "The attached files are no longer available on this desktop.",
362
+ "local_task_attachment_ref_not_found",
363
+ "reattach_file",
364
+ );
365
+ }
366
+ }
367
+
368
+ async function assertManagedRegularFile(value, parent) {
369
+ const details = await lstat(value);
370
+ if (!details.isFile() || details.isSymbolicLink()) {
371
+ throw storeError(
372
+ "The attached file failed SIMY's safety check.",
373
+ "local_task_attachment_unsafe_storage",
374
+ "reattach_file",
375
+ );
376
+ }
377
+ const [resolved, resolvedParent] = await Promise.all([
378
+ realpath(value),
379
+ realpath(parent),
380
+ ]);
381
+ if (!resolved.startsWith(`${resolvedParent}${path.sep}`)) {
382
+ throw storeError(
383
+ "The attached file failed SIMY's safety check.",
384
+ "local_task_attachment_unsafe_storage",
385
+ "reattach_file",
386
+ );
387
+ }
388
+ }
389
+
390
+ async function writeExclusive(target, bytes) {
391
+ const handle = await open(target, "wx", 0o600);
392
+ try {
393
+ await handle.writeFile(bytes);
394
+ } finally {
395
+ await handle.close();
396
+ }
397
+ await chmod(target, 0o600);
398
+ }
399
+
400
+ function sameRef(entry, ref) {
401
+ return [...REF_FIELDS].every((key) => entry[key] === ref[key]);
402
+ }
403
+
404
+ function requireDeviceId(value) {
405
+ const id = String(value || "");
406
+ if (!SAFE_DEVICE_ID.test(id)) {
407
+ throw storeError(
408
+ "Reconnect SIMY CLI before attaching files.",
409
+ "local_task_attachment_device_unavailable",
410
+ "reattach_file",
411
+ );
412
+ }
413
+ return id;
414
+ }
415
+
416
+ function requireIdempotencyKey(value) {
417
+ const key = String(value || "");
418
+ if (!SAFE_IDEMPOTENCY_KEY.test(key)) {
419
+ throw storeError(
420
+ "Attach the files again to create a new upload request.",
421
+ "local_task_attachment_idempotency_key_invalid",
422
+ "reattach_file",
423
+ );
424
+ }
425
+ return key;
426
+ }
427
+
428
+ function resolveStageRoot(root, stageId) {
429
+ if (!SAFE_STAGE_ID.test(stageId)) throw new TypeError("invalid stage id");
430
+ const resolved = path.resolve(root, stageId);
431
+ if (!resolved.startsWith(`${path.resolve(root)}${path.sep}`)) {
432
+ throw new TypeError("invalid stage root");
433
+ }
434
+ return resolved;
435
+ }
436
+
437
+ function digest(value) {
438
+ return createHash("sha256").update(value).digest("hex");
439
+ }
440
+
441
+ function storeError(message, code, recoveryId) {
442
+ return new LocalTaskFileCapabilityError(message, {
443
+ code,
444
+ recoveryAction:
445
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions[recoveryId],
446
+ });
447
+ }