@sanlabs/sanbox-cli 0.0.3 → 0.0.5
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 +70 -77
- package/dist/api.js +102 -23
- package/dist/args.js +23 -7
- package/dist/artifacts.js +124 -0
- package/dist/cli.js +548 -222
- package/dist/config.js +2 -9
- package/dist/inputs.js +150 -0
- package/dist/output.js +3 -1
- package/dist/runs.js +13 -25
- package/dist/version.js +1 -1
- package/dist/watch.js +61 -2
- package/package.json +1 -1
- package/dist/dossier.js +0 -187
package/dist/config.js
CHANGED
|
@@ -11,7 +11,6 @@ export const readLocalConfig = (cwd = process.cwd()) => {
|
|
|
11
11
|
const record = parsed;
|
|
12
12
|
return {
|
|
13
13
|
api_url: typeof record.api_url === "string" ? record.api_url : undefined,
|
|
14
|
-
org: typeof record.org === "string" ? record.org : undefined,
|
|
15
14
|
default_template: typeof record.default_template === "string" ? record.default_template : undefined
|
|
16
15
|
};
|
|
17
16
|
}
|
|
@@ -24,19 +23,13 @@ export const readLocalConfig = (cwd = process.cwd()) => {
|
|
|
24
23
|
export const readConfig = (flags = {}) => {
|
|
25
24
|
const localConfig = readLocalConfig();
|
|
26
25
|
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
27
|
-
const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
|
|
28
26
|
const apiKey = process.env.SANBOX_API_KEY || "";
|
|
29
|
-
if (!org) {
|
|
30
|
-
throw new CliError("org_required", "Organization context is required.", {
|
|
31
|
-
nextActions: [commandAction(["sanbox", "context", "--json"], "Select an organization and retry with SANBOX_ORG set.", { SANBOX_ORG: "<org-slug>" })]
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
27
|
if (!apiKey) {
|
|
35
28
|
throw new CliError("api_key_required", "SANBOX_API_KEY is required.", {
|
|
36
29
|
nextActions: [commandAction(["sanbox", "auth", "check", "--json"], "Set a Sanbox control-plane API key and check access.", { SANBOX_API_KEY: "<sanbox-api-key>" })]
|
|
37
30
|
});
|
|
38
31
|
}
|
|
39
|
-
return { apiUrl,
|
|
32
|
+
return { apiUrl, apiKey };
|
|
40
33
|
};
|
|
41
34
|
export function readTemplateSelection(flags = {}, options = {}) {
|
|
42
35
|
const flagValue = flags.template;
|
|
@@ -52,7 +45,7 @@ export function readTemplateSelection(flags = {}, options = {}) {
|
|
|
52
45
|
return null;
|
|
53
46
|
throw new CliError("template_required", "A template must be selected explicitly.", {
|
|
54
47
|
nextActions: [
|
|
55
|
-
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the
|
|
48
|
+
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the API key's organization."),
|
|
56
49
|
commandAction(["sanbox", "run", "<task>", "--template", "<template-id>"], "Run with an explicit template."),
|
|
57
50
|
commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" })
|
|
58
51
|
]
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fg from "fast-glob";
|
|
6
|
+
import ignore from "ignore";
|
|
7
|
+
import yazl from "yazl";
|
|
8
|
+
const maximumInputBytes = 64 * 1024 * 1024;
|
|
9
|
+
const maximumInputFiles = 10_000;
|
|
10
|
+
const defaultIgnorePatterns = [
|
|
11
|
+
".git/**",
|
|
12
|
+
"**/.git/**",
|
|
13
|
+
"node_modules/**",
|
|
14
|
+
"**/node_modules/**",
|
|
15
|
+
"dist/**",
|
|
16
|
+
"**/dist/**",
|
|
17
|
+
"build/**",
|
|
18
|
+
"**/build/**",
|
|
19
|
+
"coverage/**",
|
|
20
|
+
"**/coverage/**",
|
|
21
|
+
".next/**",
|
|
22
|
+
"**/.next/**",
|
|
23
|
+
".turbo/**",
|
|
24
|
+
"**/.turbo/**",
|
|
25
|
+
".env",
|
|
26
|
+
".env.*",
|
|
27
|
+
"**/.env",
|
|
28
|
+
"**/.env.*",
|
|
29
|
+
"**/*secret*",
|
|
30
|
+
"**/*Secret*",
|
|
31
|
+
"**/*token*",
|
|
32
|
+
"**/*Token*",
|
|
33
|
+
"**/*credential*",
|
|
34
|
+
"**/*Credential*",
|
|
35
|
+
"**/*.pem",
|
|
36
|
+
"**/*.key",
|
|
37
|
+
"**/id_rsa",
|
|
38
|
+
"**/id_ed25519"
|
|
39
|
+
];
|
|
40
|
+
const normalizePattern = async (cwd, pattern) => {
|
|
41
|
+
const slashPattern = pattern.replaceAll(path.sep, "/");
|
|
42
|
+
const normalizedPath = path.posix.normalize(slashPattern);
|
|
43
|
+
if (path.isAbsolute(pattern) || normalizedPath === ".." || normalizedPath.startsWith("../")) {
|
|
44
|
+
throw new Error(`Input path must be relative to the current directory: ${pattern}`);
|
|
45
|
+
}
|
|
46
|
+
if (pattern === ".")
|
|
47
|
+
return "**/*";
|
|
48
|
+
const normalized = normalizedPath.replace(/\/+$/, "");
|
|
49
|
+
if (normalized.includes("*"))
|
|
50
|
+
return normalized;
|
|
51
|
+
try {
|
|
52
|
+
const stat = await fs.stat(path.resolve(cwd, normalized));
|
|
53
|
+
if (stat.isDirectory())
|
|
54
|
+
return `${normalized}/**`;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code !== "ENOENT")
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
};
|
|
62
|
+
const readSanboxIgnore = async (cwd) => {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fs.readFile(path.join(cwd, ".sanboxignore"), "utf8");
|
|
65
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === "ENOENT")
|
|
69
|
+
return [];
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const selectedInputs = async (cwd, inputs) => {
|
|
74
|
+
const patterns = await Promise.all(inputs.map((input) => normalizePattern(cwd, input)));
|
|
75
|
+
const ignored = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(cwd))]);
|
|
76
|
+
const candidates = await fg(patterns, {
|
|
77
|
+
cwd,
|
|
78
|
+
dot: true,
|
|
79
|
+
onlyFiles: true,
|
|
80
|
+
followSymbolicLinks: false,
|
|
81
|
+
unique: true
|
|
82
|
+
});
|
|
83
|
+
const normalizedCandidates = candidates
|
|
84
|
+
.map((item) => item.replaceAll(path.sep, "/"))
|
|
85
|
+
.filter((item) => !item.startsWith("../") && !path.isAbsolute(item))
|
|
86
|
+
.filter((item) => !ignored.ignores(item))
|
|
87
|
+
.sort();
|
|
88
|
+
const files = [];
|
|
89
|
+
for (const item of normalizedCandidates) {
|
|
90
|
+
const stat = await fs.lstat(path.join(cwd, item));
|
|
91
|
+
if (stat.isFile())
|
|
92
|
+
files.push(item);
|
|
93
|
+
}
|
|
94
|
+
return { patterns, files };
|
|
95
|
+
};
|
|
96
|
+
const zipToBuffer = async (zip) => new Promise((resolve, reject) => {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
zip.outputStream.on("data", (chunk) => chunks.push(chunk));
|
|
99
|
+
zip.outputStream.on("error", reject);
|
|
100
|
+
zip.outputStream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
101
|
+
zip.end();
|
|
102
|
+
});
|
|
103
|
+
export const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
|
|
104
|
+
export const previewInputs = async (input) => {
|
|
105
|
+
const { patterns, files } = await selectedInputs(input.cwd, input.inputs);
|
|
106
|
+
const previewFiles = [];
|
|
107
|
+
let totalBytes = 0;
|
|
108
|
+
for (const relative of files) {
|
|
109
|
+
const stat = await fs.stat(path.join(input.cwd, relative));
|
|
110
|
+
if (!stat.isFile())
|
|
111
|
+
continue;
|
|
112
|
+
totalBytes += stat.size;
|
|
113
|
+
previewFiles.push({ path: relative, size: stat.size });
|
|
114
|
+
}
|
|
115
|
+
return { patterns, files: previewFiles, totalBytes };
|
|
116
|
+
};
|
|
117
|
+
export const buildInputBundle = async (input) => {
|
|
118
|
+
const { files } = await selectedInputs(input.cwd, input.inputs);
|
|
119
|
+
if (files.length === 0)
|
|
120
|
+
throw new Error("No files matched the supplied --input values.");
|
|
121
|
+
if (files.length > maximumInputFiles)
|
|
122
|
+
throw new Error(`Inputs exceed the ${maximumInputFiles}-file limit.`);
|
|
123
|
+
const zip = new yazl.ZipFile();
|
|
124
|
+
const metadata = [];
|
|
125
|
+
let totalBytes = 0;
|
|
126
|
+
for (const relative of files) {
|
|
127
|
+
const absolute = path.join(input.cwd, relative);
|
|
128
|
+
const file = await fs.open(absolute, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
129
|
+
try {
|
|
130
|
+
const stat = await file.stat();
|
|
131
|
+
if (!stat.isFile())
|
|
132
|
+
continue;
|
|
133
|
+
if (totalBytes + stat.size > maximumInputBytes)
|
|
134
|
+
throw new Error("Inputs exceed the 64 MiB uncompressed limit.");
|
|
135
|
+
const content = await file.readFile();
|
|
136
|
+
totalBytes += content.byteLength;
|
|
137
|
+
if (totalBytes > maximumInputBytes)
|
|
138
|
+
throw new Error("Inputs exceed the 64 MiB uncompressed limit.");
|
|
139
|
+
metadata.push({ path: relative, size: content.byteLength, sha256: sha256(content) });
|
|
140
|
+
zip.addBuffer(content, `input/${relative}`, { mode: stat.mode });
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await file.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const buffer = await zipToBuffer(zip);
|
|
147
|
+
if (buffer.byteLength > maximumInputBytes)
|
|
148
|
+
throw new Error("Inputs exceed the 64 MiB upload limit.");
|
|
149
|
+
return { buffer, sha256: sha256(buffer), files: metadata };
|
|
150
|
+
};
|
package/dist/output.js
CHANGED
|
@@ -77,7 +77,9 @@ export const summarizeRun = (payload) => {
|
|
|
77
77
|
const selection = [
|
|
78
78
|
templateId ? `template=${templateId}` : "",
|
|
79
79
|
run.provider_id ? `provider=${run.provider_id}` : "",
|
|
80
|
-
run.model_id ? `model=${run.model_id}` : ""
|
|
80
|
+
run.model_id ? `model=${run.model_id}` : "",
|
|
81
|
+
run.sandbox_state ? `sandbox=${run.sandbox_state}` : "",
|
|
82
|
+
run.snapshot_generation ? `snapshot=${run.snapshot_generation}` : ""
|
|
81
83
|
].filter(Boolean).join(" ");
|
|
82
84
|
return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
|
|
83
85
|
};
|
package/dist/runs.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
const terminalStatuses = new Set(["completed", "failed", "canceled"
|
|
6
|
-
const inlineLimitBytes = 8 * 1024 * 1024;
|
|
4
|
+
import { buildInputBundle } from "./inputs.js";
|
|
5
|
+
const terminalStatuses = new Set(["completed", "failed", "canceled"]);
|
|
7
6
|
export const isTerminalRun = (run) => terminalStatuses.has(run.status);
|
|
8
7
|
export const waitForRun = async (client, runId, options = {}) => {
|
|
9
8
|
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
@@ -18,32 +17,21 @@ export const waitForRun = async (client, runId, options = {}) => {
|
|
|
18
17
|
return payload;
|
|
19
18
|
};
|
|
20
19
|
export const createRun = async (client, options) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
let inputCollectionId;
|
|
21
|
+
if (options.inputs.length > 0) {
|
|
22
|
+
const bundle = await buildInputBundle({ cwd: options.cwd, inputs: options.inputs });
|
|
23
|
+
const uploaded = await client.uploadInputCollection({
|
|
24
|
+
buffer: bundle.buffer,
|
|
25
|
+
sha256: bundle.sha256,
|
|
26
|
+
fileCount: bundle.files.length
|
|
28
27
|
});
|
|
29
|
-
|
|
30
|
-
throw new Error(`Dossier is ${dossier.buffer.byteLength} bytes; inline dossier limit is ${inlineLimitBytes} bytes.`);
|
|
28
|
+
inputCollectionId = uploaded.input_collection.id;
|
|
31
29
|
}
|
|
32
|
-
const manifest = {
|
|
33
|
-
dossier: {
|
|
34
|
-
inline_base64: dossier.buffer.toString("base64"),
|
|
35
|
-
sha256: dossier.sha256
|
|
36
|
-
},
|
|
37
|
-
sanbox_cli: {
|
|
38
|
-
files: dossier.files,
|
|
39
|
-
source: options.dossierPath ? "dossier_file" : "generated_task"
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
30
|
return client.createRun({
|
|
43
31
|
external_run_id: options.externalRunId,
|
|
44
32
|
workload_id: options.templateId,
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
instruction: options.instruction,
|
|
34
|
+
...(inputCollectionId ? { input_collection_id: inputCollectionId } : {})
|
|
47
35
|
});
|
|
48
36
|
};
|
|
49
37
|
export const readTasks = async (tasksPath) => {
|
|
@@ -64,7 +52,7 @@ export const readTasks = async (tasksPath) => {
|
|
|
64
52
|
return {
|
|
65
53
|
task,
|
|
66
54
|
external_run_id: record.external_run_id ? String(record.external_run_id) : undefined,
|
|
67
|
-
|
|
55
|
+
input: Array.isArray(record.input) ? record.input.map(String) : undefined
|
|
68
56
|
};
|
|
69
57
|
});
|
|
70
58
|
};
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.0.
|
|
1
|
+
export const version = "0.0.5";
|
package/dist/watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SanboxApiError } from "./api.js";
|
|
2
|
-
const terminalStatuses = new Set(["completed", "failed", "canceled"
|
|
3
|
-
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"
|
|
2
|
+
const terminalStatuses = new Set(["completed", "failed", "canceled"]);
|
|
3
|
+
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled"]);
|
|
4
4
|
const retryableNetworkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]);
|
|
5
5
|
export class WatchInterruptedError extends Error {
|
|
6
6
|
constructor() {
|
|
@@ -38,6 +38,65 @@ const assertActive = (signal, deadline, now, runId) => {
|
|
|
38
38
|
throw new Error(`Timed out watching run ${runId}.`);
|
|
39
39
|
};
|
|
40
40
|
export const isTerminalStatus = (status) => terminalStatuses.has(status);
|
|
41
|
+
export const watchEventsUntil = async (client, runId, options) => {
|
|
42
|
+
const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
|
|
43
|
+
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
|
|
44
|
+
const timeoutSeconds = Math.max(1, options.timeoutSeconds ?? 1800);
|
|
45
|
+
const maxReconnectDelayMs = Math.max(250, options.maxReconnectDelayMs ?? 10_000);
|
|
46
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
47
|
+
const now = options.now ?? Date.now;
|
|
48
|
+
const random = options.random ?? Math.random;
|
|
49
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
50
|
+
let cursor = Math.max(0, Math.floor(options.afterEventId ?? 0));
|
|
51
|
+
const request = async (operation) => {
|
|
52
|
+
let attempt = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
assertActive(options.signal, deadline, now, runId);
|
|
55
|
+
try {
|
|
56
|
+
return await operation();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (options.signal?.aborted)
|
|
60
|
+
throw new WatchInterruptedError();
|
|
61
|
+
if (error instanceof WatchInterruptedError || !isRetryable(error))
|
|
62
|
+
throw error;
|
|
63
|
+
attempt += 1;
|
|
64
|
+
const exponential = Math.min(maxReconnectDelayMs, 250 * (2 ** Math.min(attempt - 1, 8)));
|
|
65
|
+
const remainingMs = deadline - now();
|
|
66
|
+
if (remainingMs <= 0)
|
|
67
|
+
throw new Error(`Timed out watching run ${runId}.`);
|
|
68
|
+
const delayMs = Math.min(remainingMs, Math.max(1, Math.floor(exponential * (0.8 + random() * 0.4))));
|
|
69
|
+
options.onRetry?.({ error, attempt, delayMs });
|
|
70
|
+
await sleep(delayMs, options.signal);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (;;) {
|
|
75
|
+
assertActive(options.signal, deadline, now, runId);
|
|
76
|
+
let delivered = 0;
|
|
77
|
+
let fetchAnotherPage = true;
|
|
78
|
+
while (fetchAnotherPage) {
|
|
79
|
+
const page = await request(() => client.listEvents(runId, cursor, pageSize, options.signal));
|
|
80
|
+
const previousCursor = cursor;
|
|
81
|
+
const ordered = [...page.events].sort((left, right) => left.id - right.id);
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const event of ordered) {
|
|
84
|
+
if (!Number.isSafeInteger(event.id) || event.id <= cursor || seen.has(event.id))
|
|
85
|
+
continue;
|
|
86
|
+
seen.add(event.id);
|
|
87
|
+
await options.onEvent?.(event);
|
|
88
|
+
cursor = event.id;
|
|
89
|
+
delivered += 1;
|
|
90
|
+
if (options.stopWhen(event))
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
const reportedMore = page.has_more ?? page.events.length >= pageSize;
|
|
94
|
+
fetchAnotherPage = reportedMore && cursor > previousCursor;
|
|
95
|
+
}
|
|
96
|
+
if (delivered === 0)
|
|
97
|
+
await sleep(pollIntervalMs, options.signal);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
41
100
|
export const watchRun = async (client, runId, options) => {
|
|
42
101
|
const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
|
|
43
102
|
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
|
package/package.json
CHANGED
package/dist/dossier.js
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { inflateRawSync } from "node:zlib";
|
|
5
|
-
import fg from "fast-glob";
|
|
6
|
-
import ignore from "ignore";
|
|
7
|
-
import yazl from "yazl";
|
|
8
|
-
const defaultIgnorePatterns = [
|
|
9
|
-
".git/**",
|
|
10
|
-
"**/.git/**",
|
|
11
|
-
"node_modules/**",
|
|
12
|
-
"**/node_modules/**",
|
|
13
|
-
"dist/**",
|
|
14
|
-
"**/dist/**",
|
|
15
|
-
"build/**",
|
|
16
|
-
"**/build/**",
|
|
17
|
-
"coverage/**",
|
|
18
|
-
"**/coverage/**",
|
|
19
|
-
".next/**",
|
|
20
|
-
"**/.next/**",
|
|
21
|
-
".turbo/**",
|
|
22
|
-
"**/.turbo/**",
|
|
23
|
-
".env",
|
|
24
|
-
".env.*",
|
|
25
|
-
"**/.env",
|
|
26
|
-
"**/.env.*",
|
|
27
|
-
"**/*secret*",
|
|
28
|
-
"**/*Secret*",
|
|
29
|
-
"**/*token*",
|
|
30
|
-
"**/*Token*",
|
|
31
|
-
"**/*credential*",
|
|
32
|
-
"**/*Credential*",
|
|
33
|
-
"**/*.pem",
|
|
34
|
-
"**/*.key",
|
|
35
|
-
"**/id_rsa",
|
|
36
|
-
"**/id_ed25519"
|
|
37
|
-
];
|
|
38
|
-
const normalizePattern = async (cwd, pattern) => {
|
|
39
|
-
if (pattern === ".")
|
|
40
|
-
return "**/*";
|
|
41
|
-
const normalized = pattern.replaceAll(path.sep, "/").replace(/\/+$/, "");
|
|
42
|
-
if (normalized.includes("*"))
|
|
43
|
-
return normalized;
|
|
44
|
-
try {
|
|
45
|
-
const stat = await fs.stat(path.resolve(cwd, normalized));
|
|
46
|
-
if (stat.isDirectory())
|
|
47
|
-
return `${normalized}/**`;
|
|
48
|
-
}
|
|
49
|
-
catch (error) {
|
|
50
|
-
if (error.code !== "ENOENT")
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
return normalized;
|
|
54
|
-
};
|
|
55
|
-
const normalizeInclude = async (cwd, patterns) => {
|
|
56
|
-
if (patterns.length === 0)
|
|
57
|
-
return ["**/*"];
|
|
58
|
-
return Promise.all(patterns.map((pattern) => normalizePattern(cwd, pattern)));
|
|
59
|
-
};
|
|
60
|
-
const readSanboxIgnore = async (cwd) => {
|
|
61
|
-
try {
|
|
62
|
-
const raw = await fs.readFile(path.join(cwd, ".sanboxignore"), "utf8");
|
|
63
|
-
return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
if (error.code === "ENOENT")
|
|
67
|
-
return [];
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
const zipToBuffer = async (zip) => new Promise((resolve, reject) => {
|
|
72
|
-
const chunks = [];
|
|
73
|
-
zip.outputStream.on("data", (chunk) => chunks.push(chunk));
|
|
74
|
-
zip.outputStream.on("error", reject);
|
|
75
|
-
zip.outputStream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
76
|
-
zip.end();
|
|
77
|
-
});
|
|
78
|
-
export const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
|
|
79
|
-
const selectedFiles = async (cwd, include) => {
|
|
80
|
-
const patterns = await normalizeInclude(cwd, include);
|
|
81
|
-
const ig = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(cwd))]);
|
|
82
|
-
const candidates = await fg(patterns, {
|
|
83
|
-
cwd,
|
|
84
|
-
dot: true,
|
|
85
|
-
onlyFiles: true,
|
|
86
|
-
followSymbolicLinks: false,
|
|
87
|
-
unique: true
|
|
88
|
-
});
|
|
89
|
-
const files = candidates
|
|
90
|
-
.map((item) => item.replaceAll(path.sep, "/"))
|
|
91
|
-
.filter((item) => !item.startsWith("../") && !path.isAbsolute(item))
|
|
92
|
-
.filter((item) => !ig.ignores(item))
|
|
93
|
-
.sort();
|
|
94
|
-
return { patterns, files };
|
|
95
|
-
};
|
|
96
|
-
export const previewTaskDossier = async (input) => {
|
|
97
|
-
const { patterns, files } = await selectedFiles(input.cwd, input.include);
|
|
98
|
-
const previewFiles = [];
|
|
99
|
-
let totalBytes = 0;
|
|
100
|
-
for (const rel of files) {
|
|
101
|
-
const stat = await fs.stat(path.join(input.cwd, rel));
|
|
102
|
-
if (!stat.isFile())
|
|
103
|
-
continue;
|
|
104
|
-
totalBytes += stat.size;
|
|
105
|
-
previewFiles.push({ path: rel, size: stat.size });
|
|
106
|
-
}
|
|
107
|
-
return { patterns, files: previewFiles, totalBytes };
|
|
108
|
-
};
|
|
109
|
-
export const buildTaskDossier = async (input) => {
|
|
110
|
-
const { files } = await selectedFiles(input.cwd, input.include);
|
|
111
|
-
const zip = new yazl.ZipFile();
|
|
112
|
-
const fileMetadata = [];
|
|
113
|
-
const runbook = [
|
|
114
|
-
"# Sanbox Task",
|
|
115
|
-
"",
|
|
116
|
-
"Follow these instructions exactly.",
|
|
117
|
-
"",
|
|
118
|
-
input.task.trim(),
|
|
119
|
-
"",
|
|
120
|
-
"Write durable results under `/workspace/output`.",
|
|
121
|
-
"Use files under `/workspace/dossier/input/repo` as the provided source context."
|
|
122
|
-
].join("\n");
|
|
123
|
-
zip.addBuffer(Buffer.from(runbook, "utf8"), "RUNBOOK.md");
|
|
124
|
-
for (const rel of files) {
|
|
125
|
-
const abs = path.join(input.cwd, rel);
|
|
126
|
-
const stat = await fs.stat(abs);
|
|
127
|
-
if (!stat.isFile())
|
|
128
|
-
continue;
|
|
129
|
-
const content = await fs.readFile(abs);
|
|
130
|
-
fileMetadata.push({ path: rel, size: stat.size, sha256: sha256(content) });
|
|
131
|
-
zip.addBuffer(content, `input/repo/${rel}`);
|
|
132
|
-
}
|
|
133
|
-
const manifest = {
|
|
134
|
-
source: "sanbox-cli",
|
|
135
|
-
cli_version: input.cliVersion,
|
|
136
|
-
created_at: new Date().toISOString(),
|
|
137
|
-
task: input.task,
|
|
138
|
-
files: fileMetadata
|
|
139
|
-
};
|
|
140
|
-
zip.addBuffer(Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"), "manifest.json");
|
|
141
|
-
const buffer = await zipToBuffer(zip);
|
|
142
|
-
return { buffer, sha256: sha256(buffer), files: fileMetadata };
|
|
143
|
-
};
|
|
144
|
-
export const readDossierFile = async (dossierPath) => {
|
|
145
|
-
const buffer = await fs.readFile(dossierPath);
|
|
146
|
-
return { buffer, sha256: sha256(buffer), files: [] };
|
|
147
|
-
};
|
|
148
|
-
export const writeTaskDossier = async (input) => {
|
|
149
|
-
const dossier = await buildTaskDossier(input);
|
|
150
|
-
await fs.mkdir(path.dirname(input.outPath), { recursive: true });
|
|
151
|
-
await fs.writeFile(input.outPath, dossier.buffer);
|
|
152
|
-
return dossier;
|
|
153
|
-
};
|
|
154
|
-
const readUInt16 = (buffer, offset) => buffer.readUInt16LE(offset);
|
|
155
|
-
const readUInt32 = (buffer, offset) => buffer.readUInt32LE(offset);
|
|
156
|
-
export const inspectDossierFile = async (dossierPath) => {
|
|
157
|
-
const buffer = await fs.readFile(dossierPath);
|
|
158
|
-
const entries = [];
|
|
159
|
-
let runbook;
|
|
160
|
-
let manifest;
|
|
161
|
-
let offset = 0;
|
|
162
|
-
while (offset + 30 <= buffer.length && readUInt32(buffer, offset) === 0x04034b50) {
|
|
163
|
-
const compressionMethod = readUInt16(buffer, offset + 8);
|
|
164
|
-
const compressedSize = readUInt32(buffer, offset + 18);
|
|
165
|
-
const uncompressedSize = readUInt32(buffer, offset + 22);
|
|
166
|
-
const nameLength = readUInt16(buffer, offset + 26);
|
|
167
|
-
const extraLength = readUInt16(buffer, offset + 28);
|
|
168
|
-
const nameStart = offset + 30;
|
|
169
|
-
const dataStart = nameStart + nameLength + extraLength;
|
|
170
|
-
const dataEnd = dataStart + compressedSize;
|
|
171
|
-
if (dataEnd > buffer.length)
|
|
172
|
-
break;
|
|
173
|
-
const entryPath = buffer.subarray(nameStart, nameStart + nameLength).toString("utf8");
|
|
174
|
-
entries.push({ path: entryPath, compressedSize, uncompressedSize });
|
|
175
|
-
if (entryPath === "RUNBOOK.md" || entryPath === "manifest.json") {
|
|
176
|
-
const compressed = buffer.subarray(dataStart, dataEnd);
|
|
177
|
-
const content = compressionMethod === 0 ? compressed : inflateRawSync(compressed);
|
|
178
|
-
const text = content.toString("utf8");
|
|
179
|
-
if (entryPath === "RUNBOOK.md")
|
|
180
|
-
runbook = text;
|
|
181
|
-
if (entryPath === "manifest.json")
|
|
182
|
-
manifest = JSON.parse(text);
|
|
183
|
-
}
|
|
184
|
-
offset = dataEnd;
|
|
185
|
-
}
|
|
186
|
-
return { entries, runbook, manifest };
|
|
187
|
-
};
|