@awak-app/simy-cli 0.5.34 → 0.5.35
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.
- package/README.md +41 -0
- package/package.json +4 -3
- package/src/agent.js +144 -2
- package/src/backend-executable.js +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +654 -35
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +20 -2
- package/src/codex-work-pattern-sync.js +940 -0
- package/src/delivery-hooks/command.js +28 -7
- package/src/delivery-hooks/desktop.js +38 -4
- package/src/delivery-hooks/supervisor.js +281 -0
- package/src/desktop-host.js +62 -7
- package/src/index.js +7 -0
- package/src/local-task.js +96 -3
- package/src/orchestrator/budget.js +26 -2
- package/src/runner.js +43 -8
- package/src/session-supervision/claude-sessions.js +131 -0
- package/src/session-supervision/codex-app-server.js +290 -0
- package/src/session-supervision/index.js +140 -0
- package/src/session-supervision/original-session-target.js +20 -0
- package/src/windows-update.js +28 -2
- package/src/workflow-deliverable-upload.js +454 -0
- package/src/workflow-observation-sender.js +348 -86
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { buildLocalTaskArtifactRefs } from "./local-task-artifact-contract.js";
|
|
4
|
+
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WorkFlow deliverable device upload (issue #2550 G3, backend
|
|
8
|
+
* contracts/workflow-device-upload.v1.json).
|
|
9
|
+
*
|
|
10
|
+
* What counts as a deliverable: only the verified files a Local Task Provider
|
|
11
|
+
* produced beneath the task's managed output directory (`outputs_root`), as
|
|
12
|
+
* collected by collectLocalTaskArtifacts() after the Provider finished. Files
|
|
13
|
+
* elsewhere in the workspace are never uploaded, and every byte read goes
|
|
14
|
+
* through the containment-checked localTaskArtifact() reader.
|
|
15
|
+
*
|
|
16
|
+
* Order (backend authorization requires a consumed, succeeded receipt):
|
|
17
|
+
* 1. the typed terminal was already accepted with outcome "succeeded";
|
|
18
|
+
* 2. POST the verified manifest to the device gateway
|
|
19
|
+
* `workflow-dev-work-events/deliverables` (Crew submits it to Backend);
|
|
20
|
+
* 3. per file: claim at functions/v1/workflow-deliverable-upload with the
|
|
21
|
+
* device bearer, then PUT the exact bytes to the signed upload_url.
|
|
22
|
+
*
|
|
23
|
+
* Never throws and never logs; the returned summary contains no token, signed
|
|
24
|
+
* URL or absolute path, so it is safe to persist in the Local Task snapshot.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const WORKFLOW_DELIVERABLES_PATH = "workflow-dev-work-events/deliverables";
|
|
28
|
+
export const DELIVERABLE_MANIFEST_CONTRACT = "independent_workflow_deliverable_manifest.v1";
|
|
29
|
+
export const DEVICE_UPLOAD_CONTRACT = "workflow_device_upload.v1";
|
|
30
|
+
export const MAX_DELIVERABLE_BYTES = 52_428_800;
|
|
31
|
+
export const UPLOAD_FUNCTION_PATH = "/functions/v1/workflow-deliverable-upload";
|
|
32
|
+
export const UPLOAD_ENDPOINT_ENV = "SIMY_WORKFLOW_DELIVERABLE_UPLOAD_URL";
|
|
33
|
+
|
|
34
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
36
|
+
const SUPABASE_HOST = /^[a-z0-9]{20}\.supabase\.co$/;
|
|
37
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
38
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
39
|
+
const PUT_TIMEOUT_MS = 120_000;
|
|
40
|
+
// Crew may not have consumed the terminal receipt yet when the manifest
|
|
41
|
+
// arrives (409), and a claim returns nothing until the job exists.
|
|
42
|
+
const MANIFEST_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000];
|
|
43
|
+
const CLAIM_IDLE_DELAYS_MS = [2_000, 5_000, 10_000];
|
|
44
|
+
const PUT_ATTEMPTS = 3;
|
|
45
|
+
const PUT_RETRY_DELAY_MS = 2_000;
|
|
46
|
+
|
|
47
|
+
export async function uploadWorkflowDeliverables({
|
|
48
|
+
binding,
|
|
49
|
+
observationId,
|
|
50
|
+
localTaskId,
|
|
51
|
+
artifacts,
|
|
52
|
+
readArtifactBytes,
|
|
53
|
+
session,
|
|
54
|
+
apiOrigin,
|
|
55
|
+
fetchImpl = globalThis.fetch,
|
|
56
|
+
environment = process.env,
|
|
57
|
+
sleep = defaultSleep,
|
|
58
|
+
manifestRetryDelaysMs = MANIFEST_RETRY_DELAYS_MS,
|
|
59
|
+
claimIdleDelaysMs = CLAIM_IDLE_DELAYS_MS,
|
|
60
|
+
}) {
|
|
61
|
+
let manifest;
|
|
62
|
+
try {
|
|
63
|
+
manifest = buildLocalTaskArtifactRefs(artifacts, localTaskId);
|
|
64
|
+
} catch {
|
|
65
|
+
return summary("failed", { reason_code: "deliverable_manifest_invalid" });
|
|
66
|
+
}
|
|
67
|
+
if (manifest.length === 0) return summary("skipped", { reason_code: "no_deliverables" });
|
|
68
|
+
const base = { manifest_files: manifest.length };
|
|
69
|
+
if (!session?.token || !session?.api_base_url) {
|
|
70
|
+
return summary("failed", { ...base, reason_code: "device_session_unavailable" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const submitted = await submitManifest({
|
|
75
|
+
binding, observationId, manifest, session, apiOrigin, fetchImpl, sleep,
|
|
76
|
+
delays: manifestRetryDelaysMs,
|
|
77
|
+
});
|
|
78
|
+
if (!submitted.ok) return summary("failed", { ...base, ...submitted.reason });
|
|
79
|
+
|
|
80
|
+
const endpoint = resolveUploadEndpoint(environment?.[UPLOAD_ENDPOINT_ENV], submitted.payload);
|
|
81
|
+
if (!endpoint) return summary("failed", { ...base, reason_code: "upload_endpoint_unavailable" });
|
|
82
|
+
const orgId = firstUuid(session.organization_id, submitted.payload.org_id);
|
|
83
|
+
const authUserId = firstUuid(session.account_id, submitted.payload.auth_user_id);
|
|
84
|
+
if (!orgId || !authUserId) {
|
|
85
|
+
return summary("failed", { ...base, reason_code: "device_identity_unavailable" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = await claimAndUpload({
|
|
89
|
+
endpoint,
|
|
90
|
+
claimBody: {
|
|
91
|
+
action: "claim",
|
|
92
|
+
device_token: session.token,
|
|
93
|
+
org_id: orgId,
|
|
94
|
+
auth_user_id: authUserId,
|
|
95
|
+
workflow_run_id: binding.workflow_run_id,
|
|
96
|
+
observation_id: observationId,
|
|
97
|
+
source_task_id: localTaskId,
|
|
98
|
+
},
|
|
99
|
+
manifest,
|
|
100
|
+
localTaskId,
|
|
101
|
+
readArtifactBytes,
|
|
102
|
+
fetchImpl,
|
|
103
|
+
sleep,
|
|
104
|
+
idleDelays: claimIdleDelaysMs,
|
|
105
|
+
});
|
|
106
|
+
const status =
|
|
107
|
+
result.failures.length === 0 && result.uploaded.length === manifest.length
|
|
108
|
+
? "completed"
|
|
109
|
+
: result.uploaded.length > 0
|
|
110
|
+
? "partial"
|
|
111
|
+
: "failed";
|
|
112
|
+
return summary(status, {
|
|
113
|
+
...base,
|
|
114
|
+
...(status === "completed" ? {} : { reason_code: result.reasonCode }),
|
|
115
|
+
uploaded: result.uploaded,
|
|
116
|
+
failures: result.failures,
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
return summary("failed", { ...base, reason_code: "deliverable_upload_error" });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function submitManifest({
|
|
124
|
+
binding, observationId, manifest, session, apiOrigin, fetchImpl, sleep, delays,
|
|
125
|
+
}) {
|
|
126
|
+
const body = JSON.stringify({
|
|
127
|
+
workflow_id: binding.workflow_id,
|
|
128
|
+
workflow_run_id: binding.workflow_run_id,
|
|
129
|
+
action_digest: binding.action_digest,
|
|
130
|
+
observation_id: observationId,
|
|
131
|
+
manifest,
|
|
132
|
+
});
|
|
133
|
+
let lastStatus = null;
|
|
134
|
+
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
|
|
135
|
+
if (attempt > 0) await sleep(delays[attempt - 1]);
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetchImpl(
|
|
139
|
+
webApiUrl(WORKFLOW_DELIVERABLES_PATH, {
|
|
140
|
+
webOrigin: apiOrigin,
|
|
141
|
+
apiBaseUrl: session.api_base_url,
|
|
142
|
+
}),
|
|
143
|
+
{
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: webApiHeaders({ token: session.token }, { "Content-Type": "application/json" }),
|
|
146
|
+
body,
|
|
147
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
lastStatus = null;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
lastStatus = response.status;
|
|
155
|
+
// 409: receipt not consumed yet; 5xx: transient. Both are safe to resend
|
|
156
|
+
// because Backend treats an identical manifest as the same submission.
|
|
157
|
+
if (response.status === 409 || response.status >= 500) continue;
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
return { ok: false, reason: httpReason(response.status, "deliverable_manifest_rejected") };
|
|
160
|
+
}
|
|
161
|
+
const payload = await response.json().catch(() => null);
|
|
162
|
+
if (
|
|
163
|
+
!payload ||
|
|
164
|
+
typeof payload !== "object" ||
|
|
165
|
+
payload.contract_version !== DELIVERABLE_MANIFEST_CONTRACT ||
|
|
166
|
+
payload.accepted !== true
|
|
167
|
+
) {
|
|
168
|
+
return { ok: false, reason: { reason_code: "invalid_deliverable_manifest_receipt" } };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, payload };
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: lastStatus === 409
|
|
175
|
+
? { reason_code: "deliverable_manifest_not_admitted", http_status: 409 }
|
|
176
|
+
: { reason_code: "deliverable_service_unavailable" },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function claimAndUpload({
|
|
181
|
+
endpoint, claimBody, manifest, localTaskId, readArtifactBytes, fetchImpl, sleep, idleDelays,
|
|
182
|
+
}) {
|
|
183
|
+
const pending = new Map(manifest.map((entry) => [entry.relative_path, entry]));
|
|
184
|
+
const putAttempts = new Map();
|
|
185
|
+
const uploaded = [];
|
|
186
|
+
const failures = [];
|
|
187
|
+
let reasonCode = null;
|
|
188
|
+
let idle = 0;
|
|
189
|
+
// Hard bound: every file may need a few claims, plus idle waits.
|
|
190
|
+
let claimBudget = manifest.length * (PUT_ATTEMPTS + 1) + idleDelays.length + 1;
|
|
191
|
+
|
|
192
|
+
const fail = (relativePath, code) => {
|
|
193
|
+
reasonCode ||= code;
|
|
194
|
+
failures.push(relativePath === null ? { reason_code: code } : { relative_path: relativePath, reason_code: code });
|
|
195
|
+
if (relativePath !== null) pending.delete(relativePath);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
while (pending.size > 0 && claimBudget > 0) {
|
|
199
|
+
claimBudget -= 1;
|
|
200
|
+
const claim = await postClaim(endpoint, claimBody, fetchImpl);
|
|
201
|
+
if (claim.kind === "fatal") {
|
|
202
|
+
reasonCode ||= claim.reasonCode;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (claim.kind === "idle") {
|
|
206
|
+
// No pending file for this device/run/receipt/task yet (or transient).
|
|
207
|
+
if (idle >= idleDelays.length) {
|
|
208
|
+
reasonCode ||= claim.reasonCode;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
await sleep(idleDelays[idle]);
|
|
212
|
+
idle += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const job = claim.payload;
|
|
217
|
+
if (!isSafeRelativePath(job.source_relative_path)) {
|
|
218
|
+
// Path traversal or an absolute path is never read, even if listed.
|
|
219
|
+
fail(null, "claim_path_rejected");
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const entry = pending.get(job.source_relative_path);
|
|
223
|
+
if (!entry) {
|
|
224
|
+
fail(null, "claim_unknown_file");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (job.size_bytes > MAX_DELIVERABLE_BYTES || entry.size_bytes > MAX_DELIVERABLE_BYTES) {
|
|
228
|
+
fail(entry.relative_path, "file_too_large");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
job.source_task_id !== localTaskId ||
|
|
233
|
+
(job.source_artifact_id !== undefined && job.source_artifact_id !== entry.id) ||
|
|
234
|
+
job.size_bytes !== entry.size_bytes ||
|
|
235
|
+
job.sha256 !== String(entry.sha256).toLowerCase() ||
|
|
236
|
+
job.mime_type !== entry.mime_type
|
|
237
|
+
) {
|
|
238
|
+
fail(entry.relative_path, "claim_manifest_mismatch");
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!trustedUploadUrl(job.upload_url, endpoint)) {
|
|
242
|
+
fail(entry.relative_path, "untrusted_upload_url");
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let bytes;
|
|
247
|
+
try {
|
|
248
|
+
bytes = await readArtifactBytes(entry.id);
|
|
249
|
+
} catch {
|
|
250
|
+
bytes = null;
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
!bytes ||
|
|
254
|
+
bytes.length !== job.size_bytes ||
|
|
255
|
+
createHash("sha256").update(bytes).digest("hex") !== job.sha256
|
|
256
|
+
) {
|
|
257
|
+
fail(entry.relative_path, "local_file_changed");
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const put = await putWithRetry(job, bytes, fetchImpl, sleep, () => {
|
|
262
|
+
const used = (putAttempts.get(entry.relative_path) || 0) + 1;
|
|
263
|
+
putAttempts.set(entry.relative_path, used);
|
|
264
|
+
return used;
|
|
265
|
+
});
|
|
266
|
+
if (put.kind === "uploaded") {
|
|
267
|
+
uploaded.push({ artifact_id: put.artifactId, relative_path: entry.relative_path });
|
|
268
|
+
pending.delete(entry.relative_path);
|
|
269
|
+
} else if (put.kind === "reclaim") {
|
|
270
|
+
// The signed URL or lease expired; the next claim reissues the same
|
|
271
|
+
// stable artifact_id unless attempts are exhausted.
|
|
272
|
+
if ((putAttempts.get(entry.relative_path) || 0) >= PUT_ATTEMPTS) {
|
|
273
|
+
fail(entry.relative_path, put.reasonCode);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
fail(entry.relative_path, put.reasonCode);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const relativePath of pending.keys()) {
|
|
280
|
+
failures.push({ relative_path: relativePath, reason_code: reasonCode || "claim_unavailable" });
|
|
281
|
+
}
|
|
282
|
+
if (failures.length && !reasonCode) reasonCode = failures[0].reason_code;
|
|
283
|
+
return { uploaded, failures, reasonCode };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function postClaim(endpoint, claimBody, fetchImpl) {
|
|
287
|
+
let response;
|
|
288
|
+
try {
|
|
289
|
+
response = await fetchImpl(endpoint.href, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Type": "application/json" },
|
|
292
|
+
body: JSON.stringify(claimBody),
|
|
293
|
+
redirect: "error",
|
|
294
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
295
|
+
});
|
|
296
|
+
} catch {
|
|
297
|
+
return { kind: "idle", reasonCode: "upload_service_unavailable" };
|
|
298
|
+
}
|
|
299
|
+
if (response.status === 403) return { kind: "idle", reasonCode: "claim_unavailable" };
|
|
300
|
+
if (response.status >= 500) return { kind: "idle", reasonCode: "upload_service_unavailable" };
|
|
301
|
+
if (!response.ok) return { kind: "fatal", reasonCode: "claim_rejected" };
|
|
302
|
+
const payload = await response.json().catch(() => null);
|
|
303
|
+
if (!validClaim(payload)) return { kind: "fatal", reasonCode: "invalid_claim_response" };
|
|
304
|
+
return { kind: "claimed", payload };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function putWithRetry(job, bytes, fetchImpl, sleep, countAttempt) {
|
|
308
|
+
while (true) {
|
|
309
|
+
const attempt = countAttempt();
|
|
310
|
+
let response = null;
|
|
311
|
+
try {
|
|
312
|
+
response = await fetchImpl(job.upload_url, {
|
|
313
|
+
method: "PUT",
|
|
314
|
+
headers: {
|
|
315
|
+
"Content-Type": job.mime_type,
|
|
316
|
+
"Content-Length": String(bytes.length),
|
|
317
|
+
},
|
|
318
|
+
body: bytes,
|
|
319
|
+
redirect: "error",
|
|
320
|
+
signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
321
|
+
});
|
|
322
|
+
} catch {
|
|
323
|
+
response = null;
|
|
324
|
+
}
|
|
325
|
+
if (response?.ok) {
|
|
326
|
+
const payload = await response.json().catch(() => null);
|
|
327
|
+
if (
|
|
328
|
+
payload?.contract_version === DEVICE_UPLOAD_CONTRACT &&
|
|
329
|
+
typeof payload.artifact_id === "string" &&
|
|
330
|
+
payload.artifact_id.toLowerCase() === job.artifact_id.toLowerCase()
|
|
331
|
+
) {
|
|
332
|
+
return { kind: "uploaded", artifactId: job.artifact_id.toLowerCase() };
|
|
333
|
+
}
|
|
334
|
+
return { kind: "failed", reasonCode: "invalid_upload_response" };
|
|
335
|
+
}
|
|
336
|
+
if (response && response.status === 403) {
|
|
337
|
+
return { kind: "reclaim", reasonCode: "upload_authorization_expired" };
|
|
338
|
+
}
|
|
339
|
+
if (response && response.status === 413) return { kind: "failed", reasonCode: "file_too_large" };
|
|
340
|
+
if (response && response.status === 422) {
|
|
341
|
+
return { kind: "failed", reasonCode: "file_does_not_match_manifest" };
|
|
342
|
+
}
|
|
343
|
+
if (response && response.status < 500) {
|
|
344
|
+
return { kind: "failed", reasonCode: `upload_rejected_${response.status}` };
|
|
345
|
+
}
|
|
346
|
+
// Network failure or 5xx: the signed URL is still valid for the lease, and
|
|
347
|
+
// the gateway accepts identical immutable bytes on a repeat.
|
|
348
|
+
if (attempt >= PUT_ATTEMPTS) return { kind: "failed", reasonCode: "upload_failed_after_retries" };
|
|
349
|
+
await sleep(PUT_RETRY_DELAY_MS);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validClaim(value) {
|
|
354
|
+
return Boolean(
|
|
355
|
+
value &&
|
|
356
|
+
typeof value === "object" &&
|
|
357
|
+
value.contract_version === DEVICE_UPLOAD_CONTRACT &&
|
|
358
|
+
typeof value.artifact_id === "string" &&
|
|
359
|
+
UUID.test(value.artifact_id) &&
|
|
360
|
+
typeof value.source_relative_path === "string" &&
|
|
361
|
+
typeof value.source_task_id === "string" &&
|
|
362
|
+
Number.isSafeInteger(value.size_bytes) &&
|
|
363
|
+
value.size_bytes > 0 &&
|
|
364
|
+
typeof value.sha256 === "string" &&
|
|
365
|
+
SHA256.test(value.sha256) &&
|
|
366
|
+
typeof value.mime_type === "string" &&
|
|
367
|
+
value.mime_type.length > 0 &&
|
|
368
|
+
typeof value.upload_url === "string",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Mirrors Backend's source_relative_path CHECK constraint. */
|
|
373
|
+
export function isSafeRelativePath(value) {
|
|
374
|
+
return (
|
|
375
|
+
typeof value === "string" &&
|
|
376
|
+
value.length >= 1 &&
|
|
377
|
+
value.length <= 2048 &&
|
|
378
|
+
!/[\u0000-\u001f\u007f\\]/.test(value) &&
|
|
379
|
+
!/(^|\/)\.\.?(\/|$)/.test(value) &&
|
|
380
|
+
!/^(\/|~\/|[A-Za-z]:)/.test(value) &&
|
|
381
|
+
!value.includes("//") &&
|
|
382
|
+
!value.endsWith("/")
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The device bearer is sent only to the Backend upload function. An operator
|
|
388
|
+
* override (env) may be any https URL, or loopback http for local Supabase; an
|
|
389
|
+
* endpoint suggested by the Web/Crew response must be a hosted Supabase
|
|
390
|
+
* project URL. Both must be the exact function path with no credentials,
|
|
391
|
+
* query or fragment.
|
|
392
|
+
*/
|
|
393
|
+
export function resolveUploadEndpoint(override, payload) {
|
|
394
|
+
if (typeof override === "string" && override.trim()) {
|
|
395
|
+
const url = parseEndpoint(override.trim());
|
|
396
|
+
if (!url) return null;
|
|
397
|
+
if (url.protocol === "https:") return url;
|
|
398
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname) ? url : null;
|
|
399
|
+
}
|
|
400
|
+
const suggested = typeof payload?.upload_endpoint === "string"
|
|
401
|
+
? parseEndpoint(payload.upload_endpoint)
|
|
402
|
+
: null;
|
|
403
|
+
return suggested && suggested.protocol === "https:" && SUPABASE_HOST.test(suggested.hostname) &&
|
|
404
|
+
suggested.port === ""
|
|
405
|
+
? suggested
|
|
406
|
+
: null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function parseEndpoint(value) {
|
|
410
|
+
let url;
|
|
411
|
+
try {
|
|
412
|
+
url = new URL(value);
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
if (url.username || url.password || url.search || url.hash) return null;
|
|
417
|
+
return url.pathname === UPLOAD_FUNCTION_PATH ? url : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function trustedUploadUrl(value, endpoint) {
|
|
421
|
+
let url;
|
|
422
|
+
try {
|
|
423
|
+
url = new URL(value);
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
return (
|
|
428
|
+
url.origin === endpoint.origin &&
|
|
429
|
+
url.pathname === endpoint.pathname &&
|
|
430
|
+
!url.username &&
|
|
431
|
+
!url.password
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function firstUuid(...values) {
|
|
436
|
+
for (const value of values) {
|
|
437
|
+
if (typeof value === "string" && UUID.test(value)) return value.toLowerCase();
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function httpReason(status, fallback) {
|
|
443
|
+
if (status === 401 || status === 403) return { reason_code: "device_or_source_not_authorized", http_status: status };
|
|
444
|
+
if (status === 404 || status === 410) return { reason_code: "workflow_binding_unavailable", http_status: status };
|
|
445
|
+
return { reason_code: fallback, http_status: status };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function summary(status, extra = {}) {
|
|
449
|
+
return { status, contract_version: DEVICE_UPLOAD_CONTRACT, ...extra };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function defaultSleep(ms) {
|
|
453
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
454
|
+
}
|