@foldrun/core 0.6.0 → 0.7.0
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/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +5 -0
- package/dist/src/run-container.js +66 -13
- package/dist/src/runner.d.ts +204 -0
- package/dist/src/runner.js +257 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +11 -2
- package/dist/src/step-exec.js +52 -5
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +20 -0
- package/dist/src/store.js +89 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +65 -12
- package/src/runner.ts +264 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +56 -8
- package/src/storage.ts +13 -0
- package/src/store.ts +85 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/src/store.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
//
|
|
21
21
|
// An account is a named directory (per OKF: tenancy is a hosting concern).
|
|
22
22
|
|
|
23
|
+
import { expandPlatformVars } from "./host-env.ts";
|
|
23
24
|
import type { BrowserPodOutcome } from "./browser-pod.ts";
|
|
24
25
|
import fs from "node:fs";
|
|
25
26
|
import path from "node:path";
|
|
@@ -57,7 +58,7 @@ import {
|
|
|
57
58
|
} from "./okf.ts";
|
|
58
59
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
59
60
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
60
|
-
import { starterFiles, accountFiles } from "./starter.ts";
|
|
61
|
+
import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.ts";
|
|
61
62
|
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
62
63
|
|
|
63
64
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
@@ -317,6 +318,19 @@ export function assertCanonicalCase(rel: string) {
|
|
|
317
318
|
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
318
319
|
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
319
320
|
|
|
321
|
+
// The agent engine's own home files. Each step's sandbox runs the Claude
|
|
322
|
+
// Agent SDK — the engine, whatever model answers — with its home in the
|
|
323
|
+
// agent's folder, and the engine writes its settings (.claude.json, its
|
|
324
|
+
// backups), plugin caches and the full transcript of every session
|
|
325
|
+
// (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
|
|
326
|
+
// back after every step, they had piled up to 5,274 files and 19 MB in one
|
|
327
|
+
// agent's folder on the box by 6 Oct 2026, carried every past conversation
|
|
328
|
+
// into every sandbox, and their clashing rewrites were the "write-back
|
|
329
|
+
// conflict on .claude.json" lines (and the big merges) of that day. A
|
|
330
|
+
// workspace's own .claude/agents/*.md — Claude Code subagents an author
|
|
331
|
+
// writes, imported as agents — sits at the workspace root and is untouched.
|
|
332
|
+
const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
|
|
333
|
+
|
|
320
334
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
321
335
|
export function isPlatformPath(rel: string): boolean {
|
|
322
336
|
const norm = rel.replaceAll("\\", "/");
|
|
@@ -325,6 +339,7 @@ export function isPlatformPath(rel: string): boolean {
|
|
|
325
339
|
// are the vault, as far as anything outside the platform is concerned.
|
|
326
340
|
if (/(^|\/)secrets\.json\./.test(norm)) return true;
|
|
327
341
|
if (norm === "trigger-log.jsonl.tmp") return true;
|
|
342
|
+
if (ENGINE_HOME_RE.test(norm)) return true;
|
|
328
343
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
329
344
|
}
|
|
330
345
|
|
|
@@ -625,6 +640,27 @@ export function toolIsOutward(data: Record<string, unknown>): boolean {
|
|
|
625
640
|
return data.outward === true;
|
|
626
641
|
}
|
|
627
642
|
|
|
643
|
+
/** How much of a tool.md body rides to the model with the tool. */
|
|
644
|
+
export const TOOL_GUIDE_MAX = 6000;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The prose under a tool.md's frontmatter: how to call it, what it refuses,
|
|
648
|
+
* what its output means. Only `description:` used to reach the model, so
|
|
649
|
+
* agents invented what the body documented (5 Oct 2026). The fenced program
|
|
650
|
+
* of a single-file tool is code, not guidance, and stays out.
|
|
651
|
+
*/
|
|
652
|
+
export function toolGuide(body: string | undefined, inlineProgram = false): string | undefined {
|
|
653
|
+
if (!body) return undefined;
|
|
654
|
+
let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
655
|
+
// Only a single-file tool's program is cut; in a `run:` tool every fenced
|
|
656
|
+
// block is an example of a call, which is exactly what the model needs.
|
|
657
|
+
const inline = inlineProgram ? fencedCodeBlock(text) : null;
|
|
658
|
+
if (inline) text = text.slice(0, inline.start) + text.slice(inline.end);
|
|
659
|
+
text = text.trim();
|
|
660
|
+
if (!text) return undefined;
|
|
661
|
+
return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
|
|
662
|
+
}
|
|
663
|
+
|
|
628
664
|
export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
|
|
629
665
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
630
666
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -659,16 +695,24 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
|
|
|
659
695
|
};
|
|
660
696
|
}
|
|
661
697
|
if (kind === "script") {
|
|
698
|
+
// `guide` is the body's, never the frontmatter's: an author's `guide:`
|
|
699
|
+
// would otherwise ride to the model in place of the manual.
|
|
700
|
+
if ("guide" in data) { data = { ...data }; delete data.guide; }
|
|
662
701
|
// The program is a run: path, or the file's own fenced code block — the
|
|
663
702
|
// single-file form, which is what lets a script tool read and edit like
|
|
664
703
|
// every other markdown document.
|
|
665
|
-
if (typeof data.run === "string")
|
|
704
|
+
if (typeof data.run === "string") {
|
|
705
|
+
const guide = toolGuide(body);
|
|
706
|
+
return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
|
|
707
|
+
}
|
|
666
708
|
const inline = body ? fencedCode(body) : null;
|
|
667
709
|
if (!inline) return null;
|
|
668
|
-
|
|
710
|
+
const guide = toolGuide(body, true);
|
|
711
|
+
return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
|
|
669
712
|
}
|
|
670
713
|
const [spec] = parseApis([{ ...data, name }]);
|
|
671
|
-
|
|
714
|
+
const guide = toolGuide(body);
|
|
715
|
+
return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
|
|
672
716
|
}
|
|
673
717
|
|
|
674
718
|
/**
|
|
@@ -864,6 +908,8 @@ export interface ApiSpec {
|
|
|
864
908
|
name: string;
|
|
865
909
|
base: string;
|
|
866
910
|
description: string;
|
|
911
|
+
/** The tool.md body, for the model (see toolGuide). */
|
|
912
|
+
guide?: string;
|
|
867
913
|
headers: Record<string, string>; // values may contain ${SECRET_NAME}
|
|
868
914
|
query: Record<string, string>; // always-appended query params (e.g. api keys)
|
|
869
915
|
methods: string[]; // allowed HTTP methods; default GET only
|
|
@@ -1136,7 +1182,9 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
1136
1182
|
out.push({
|
|
1137
1183
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
1138
1184
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
1139
|
-
|
|
1185
|
+
// `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
|
|
1186
|
+
// addresses, filled here on the host so a desk never names one.
|
|
1187
|
+
base: trimSlashes(expandPlatformVars(base)),
|
|
1140
1188
|
description: typeof e.description === "string" ? e.description : "",
|
|
1141
1189
|
headers: asRecordLocal(e.headers),
|
|
1142
1190
|
query: asRecordLocal(e.query),
|
|
@@ -2875,6 +2923,12 @@ export function setWorkspaceDescription(tenant: string, workspace: string, descr
|
|
|
2875
2923
|
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
2876
2924
|
* so every workspace made from the dashboard was born in the old format.
|
|
2877
2925
|
*/
|
|
2926
|
+
/** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
|
|
2927
|
+
* minus the laptop-only files, for the same reason as templateFiles. */
|
|
2928
|
+
export function blankTemplateFiles(workspace: string): DeployFile[] {
|
|
2929
|
+
return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2878
2932
|
export function templateFiles(workspace: string): DeployFile[] {
|
|
2879
2933
|
// The starter minus what only matters on a laptop's disk: .gitignore
|
|
2880
2934
|
// guards a local clone's secrets, but the hosted store never keeps
|
|
@@ -3388,7 +3442,23 @@ export function runOutputPath(
|
|
|
3388
3442
|
|
|
3389
3443
|
export function readRun(tenant: string, workspace: string, runId: string): RunRecord | null {
|
|
3390
3444
|
const p = runFilePath(tenant, workspace, runId);
|
|
3391
|
-
|
|
3445
|
+
// EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
|
|
3446
|
+
// here can land on the just-unlinked inode and come back ESTALE (errno
|
|
3447
|
+
// -116, surfaced by Node as "Unknown system error -116", syscall read).
|
|
3448
|
+
// That isn't corruption — the next open() by path sees the committed new
|
|
3449
|
+
// inode — so re-read a few times on ESTALE and rethrow anything else.
|
|
3450
|
+
// (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
|
|
3451
|
+
// from waitForRun's poll while the run itself completed fine.)
|
|
3452
|
+
const attempts = 5;
|
|
3453
|
+
for (let i = 1; ; i++) {
|
|
3454
|
+
try {
|
|
3455
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
3456
|
+
} catch (e) {
|
|
3457
|
+
const err = e as NodeJS.ErrnoException;
|
|
3458
|
+
if ((err.code === "ESTALE" || err.errno === -116) && i < attempts) continue;
|
|
3459
|
+
throw e;
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3392
3462
|
}
|
|
3393
3463
|
|
|
3394
3464
|
export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
@@ -3706,6 +3776,12 @@ export function runCost(run: RunRecord): number {
|
|
|
3706
3776
|
* fan-out template is a row in the trace rather than work — billing any of
|
|
3707
3777
|
* them would charge for steps the customer can see never executed, which is
|
|
3708
3778
|
* the fastest way to lose an argument about an invoice.
|
|
3779
|
+
*
|
|
3780
|
+
* Tokens count only for steps that ran on the platform's own model
|
|
3781
|
+
* credential. A step on the account's own key (`credential` "provider: …" —
|
|
3782
|
+
* a provider: block, or Settings → Model) was paid for at the provider, and
|
|
3783
|
+
* billing it again here charged a BYOK customer twice. And a carried step's
|
|
3784
|
+
* tokens were billed where it ran, like its compute.
|
|
3709
3785
|
*/
|
|
3710
3786
|
export function runMeter(run: RunRecord): {
|
|
3711
3787
|
tokenCostUsd: number;
|
|
@@ -3720,17 +3796,19 @@ export function runMeter(run: RunRecord): {
|
|
|
3720
3796
|
let computeSecs = 0;
|
|
3721
3797
|
let smallSecs = 0;
|
|
3722
3798
|
let netBytes = 0;
|
|
3799
|
+
let tokenCostUsd = 0;
|
|
3723
3800
|
for (const s of run.steps) {
|
|
3724
3801
|
if (s.status !== "completed" && s.status !== "failed") continue;
|
|
3725
3802
|
// A carried step ran — and was billed — in the run it was carried from.
|
|
3726
3803
|
if (s.carriedFrom) continue;
|
|
3727
3804
|
steps += 1;
|
|
3805
|
+
if (!(s.credential ?? "").startsWith("provider:")) tokenCostUsd += s.costUsd ?? 0;
|
|
3728
3806
|
const secs = s.computeSecs ?? 0;
|
|
3729
3807
|
computeSecs += secs;
|
|
3730
3808
|
if (s.size === "small") smallSecs += secs;
|
|
3731
3809
|
netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
|
|
3732
3810
|
}
|
|
3733
|
-
return { tokenCostUsd
|
|
3811
|
+
return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
|
|
3734
3812
|
}
|
|
3735
3813
|
|
|
3736
3814
|
/**
|
package/src/tool-names.ts
CHANGED
|
@@ -49,8 +49,10 @@ export const BUILTIN_TOOLS = new Set([
|
|
|
49
49
|
"NotebookEdit", "TodoWrite",
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
|
-
/** The platform's own groups, served in-process rather than by the SDK.
|
|
53
|
-
|
|
52
|
+
/** The platform's own groups, served in-process rather than by the SDK.
|
|
53
|
+
* `install` is granted to every agent anyway (install-tool.ts); naming it is
|
|
54
|
+
* accepted so a file that says so is not flagged. */
|
|
55
|
+
export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
|
|
54
56
|
|
|
55
57
|
/** Would this name resolve to something the runtime provides? Built-ins win a
|
|
56
58
|
* clash, so a tool file of the same name is shadowed rather than granted. */
|
package/src/tool-test.ts
CHANGED
|
@@ -277,7 +277,14 @@ export async function testTool(
|
|
|
277
277
|
});
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// The tool's own secrets, and the calling agent's `secrets:` — a run
|
|
281
|
+
// hands a script both. Testing gbp_read (whose agent, not the tool,
|
|
282
|
+
// declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
|
|
283
|
+
// same call worked (9 Oct 2026). Only an agent that grants the tool: the
|
|
284
|
+
// agents[0] stand-in above is there for its depth, not its keys.
|
|
285
|
+
const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
286
|
+
const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
|
|
287
|
+
const names = [...new Set([...own, ...granting])];
|
|
281
288
|
const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
|
|
282
289
|
const live = await liveSecrets(stored, { tenant, workspace });
|
|
283
290
|
if ("error" in live) {
|
package/src/zip.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// A small zip reader and writer — enough for an export package, no dependency.
|
|
2
|
+
//
|
|
3
|
+
// What a person downloads from "Export" has to open with a double-click on
|
|
4
|
+
// any machine, which is a zip, not a tarball. The format here is the plain
|
|
5
|
+
// one: deflate or stored entries, a central directory, no zip64, no
|
|
6
|
+
// encryption. The reader refuses anything outside that rather than guessing,
|
|
7
|
+
// and it is written to be handed untrusted bytes: every offset is bounds
|
|
8
|
+
// checked, sizes are capped before anything is inflated, and a path that
|
|
9
|
+
// climbs out (`..`, absolute, a drive letter) is an error, not a skip.
|
|
10
|
+
|
|
11
|
+
import zlib from "node:zlib";
|
|
12
|
+
|
|
13
|
+
export interface ZipEntry {
|
|
14
|
+
path: string;
|
|
15
|
+
data: Buffer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ZipError extends Error {}
|
|
19
|
+
|
|
20
|
+
export interface UnzipLimits {
|
|
21
|
+
/** Most entries the archive may hold. */
|
|
22
|
+
maxEntries?: number;
|
|
23
|
+
/** Largest one file, uncompressed. */
|
|
24
|
+
maxFileBytes?: number;
|
|
25
|
+
/** All files together, uncompressed. */
|
|
26
|
+
maxTotalBytes?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CRC_TABLE = (() => {
|
|
30
|
+
const t = new Uint32Array(256);
|
|
31
|
+
for (let n = 0; n < 256; n++) {
|
|
32
|
+
let c = n;
|
|
33
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
34
|
+
t[n] = c >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return t;
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
export function crc32(buf: Buffer): number {
|
|
40
|
+
let c = 0xffffffff;
|
|
41
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8);
|
|
42
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** DOS date and time for the entry headers. */
|
|
46
|
+
function dosTime(d: Date): { time: number; date: number } {
|
|
47
|
+
return {
|
|
48
|
+
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
|
49
|
+
date: (Math.max(0, d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function zip(entries: ZipEntry[], now = new Date()): Buffer {
|
|
54
|
+
const { time, date } = dosTime(now);
|
|
55
|
+
const locals: Buffer[] = [];
|
|
56
|
+
const centrals: Buffer[] = [];
|
|
57
|
+
let offset = 0;
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
const name = safeEntryPath(e.path);
|
|
61
|
+
if (seen.has(name)) throw new ZipError(`duplicate entry: ${name}`);
|
|
62
|
+
seen.add(name);
|
|
63
|
+
const nameBuf = Buffer.from(name, "utf8");
|
|
64
|
+
const deflated = zlib.deflateRawSync(e.data);
|
|
65
|
+
const stored = deflated.length >= e.data.length;
|
|
66
|
+
const body = stored ? e.data : deflated;
|
|
67
|
+
const crc = crc32(e.data);
|
|
68
|
+
|
|
69
|
+
const local = Buffer.alloc(30);
|
|
70
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
71
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
72
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
73
|
+
local.writeUInt16LE(stored ? 0 : 8, 8);
|
|
74
|
+
local.writeUInt16LE(time, 10);
|
|
75
|
+
local.writeUInt16LE(date, 12);
|
|
76
|
+
local.writeUInt32LE(crc, 14);
|
|
77
|
+
local.writeUInt32LE(body.length, 18);
|
|
78
|
+
local.writeUInt32LE(e.data.length, 22);
|
|
79
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
80
|
+
local.writeUInt16LE(0, 28);
|
|
81
|
+
locals.push(local, nameBuf, body);
|
|
82
|
+
|
|
83
|
+
const central = Buffer.alloc(46);
|
|
84
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
85
|
+
central.writeUInt16LE(0x0314, 4); // made by: unix, 2.0
|
|
86
|
+
central.writeUInt16LE(20, 6);
|
|
87
|
+
central.writeUInt16LE(0x0800, 8);
|
|
88
|
+
central.writeUInt16LE(stored ? 0 : 8, 10);
|
|
89
|
+
central.writeUInt16LE(time, 12);
|
|
90
|
+
central.writeUInt16LE(date, 14);
|
|
91
|
+
central.writeUInt32LE(crc, 16);
|
|
92
|
+
central.writeUInt32LE(body.length, 20);
|
|
93
|
+
central.writeUInt32LE(e.data.length, 24);
|
|
94
|
+
central.writeUInt16LE(nameBuf.length, 28);
|
|
95
|
+
central.writeUInt32LE((0o100644 << 16) >>> 0, 38); // a regular file, rw-r--r--
|
|
96
|
+
central.writeUInt32LE(offset, 42);
|
|
97
|
+
centrals.push(central, nameBuf);
|
|
98
|
+
|
|
99
|
+
offset += local.length + nameBuf.length + body.length;
|
|
100
|
+
}
|
|
101
|
+
const cd = Buffer.concat(centrals);
|
|
102
|
+
const end = Buffer.alloc(22);
|
|
103
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
104
|
+
end.writeUInt16LE(entries.length, 8);
|
|
105
|
+
end.writeUInt16LE(entries.length, 10);
|
|
106
|
+
end.writeUInt32LE(cd.length, 12);
|
|
107
|
+
end.writeUInt32LE(offset, 16);
|
|
108
|
+
return Buffer.concat([...locals, cd, end]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function unzip(buf: Buffer, limits: UnzipLimits = {}): ZipEntry[] {
|
|
112
|
+
const maxEntries = limits.maxEntries ?? 2000;
|
|
113
|
+
const maxFile = limits.maxFileBytes ?? 1024 * 1024;
|
|
114
|
+
const maxTotal = limits.maxTotalBytes ?? 50 * 1024 * 1024;
|
|
115
|
+
|
|
116
|
+
// The end-of-central-directory record: within the last 22 + 65535 bytes.
|
|
117
|
+
let eocd = -1;
|
|
118
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
|
|
119
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
120
|
+
eocd = i;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (eocd < 0) throw new ZipError("not a zip file");
|
|
125
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
126
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
127
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
128
|
+
if (count === 0xffff || cdOffset === 0xffffffff) throw new ZipError("zip64 archives are not supported");
|
|
129
|
+
if (count > maxEntries) throw new ZipError(`too many files: ${count} (at most ${maxEntries})`);
|
|
130
|
+
if (cdOffset + cdSize > eocd) throw new ZipError("corrupt zip: central directory out of range");
|
|
131
|
+
|
|
132
|
+
const out: ZipEntry[] = [];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
let total = 0;
|
|
135
|
+
let p = cdOffset;
|
|
136
|
+
for (let n = 0; n < count; n++) {
|
|
137
|
+
if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50) throw new ZipError("corrupt zip: bad central directory entry");
|
|
138
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
139
|
+
const method = buf.readUInt16LE(p + 10);
|
|
140
|
+
const crc = buf.readUInt32LE(p + 16);
|
|
141
|
+
const csize = buf.readUInt32LE(p + 20);
|
|
142
|
+
const usize = buf.readUInt32LE(p + 24);
|
|
143
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
144
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
145
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
146
|
+
const localAt = buf.readUInt32LE(p + 42);
|
|
147
|
+
if (p + 46 + nameLen > eocd) throw new ZipError("corrupt zip: name out of range");
|
|
148
|
+
const rawName = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
|
|
149
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
150
|
+
|
|
151
|
+
if (rawName.endsWith("/")) continue; // a directory entry
|
|
152
|
+
// macOS "Compress" adds these beside every file; they are not content.
|
|
153
|
+
if (/(^|\/)__MACOSX\//.test(rawName) || /(^|\/)\.DS_Store$/.test(rawName)) continue;
|
|
154
|
+
if (flags & 0x1) throw new ZipError(`${rawName}: encrypted entries are not supported`);
|
|
155
|
+
if (method !== 0 && method !== 8) throw new ZipError(`${rawName}: compression method ${method} is not supported`);
|
|
156
|
+
const name = safeEntryPath(rawName);
|
|
157
|
+
if (seen.has(name)) throw new ZipError(`duplicate entry: ${name}`);
|
|
158
|
+
seen.add(name);
|
|
159
|
+
if (usize > maxFile) throw new ZipError(`${name} is too large (${usize} bytes, at most ${maxFile})`);
|
|
160
|
+
total += usize;
|
|
161
|
+
if (total > maxTotal) throw new ZipError(`the files are too large together (more than ${maxTotal} bytes)`);
|
|
162
|
+
|
|
163
|
+
if (localAt + 30 > buf.length || buf.readUInt32LE(localAt) !== 0x04034b50) throw new ZipError(`${name}: corrupt local header`);
|
|
164
|
+
const start = localAt + 30 + buf.readUInt16LE(localAt + 26) + buf.readUInt16LE(localAt + 28);
|
|
165
|
+
if (start + csize > buf.length) throw new ZipError(`${name}: data out of range`);
|
|
166
|
+
const raw = buf.subarray(start, start + csize);
|
|
167
|
+
let data: Buffer;
|
|
168
|
+
try {
|
|
169
|
+
// maxOutputLength: the header's size is the attacker's to choose, so the
|
|
170
|
+
// inflate itself is capped too — a bomb stops here, not in memory.
|
|
171
|
+
data = method === 0 ? Buffer.from(raw) : zlib.inflateRawSync(raw, { maxOutputLength: maxFile + 1 });
|
|
172
|
+
} catch {
|
|
173
|
+
throw new ZipError(`${name}: could not be decompressed`);
|
|
174
|
+
}
|
|
175
|
+
if (data.length !== usize) throw new ZipError(`${name}: size does not match its header`);
|
|
176
|
+
if (crc32(data) !== crc) throw new ZipError(`${name}: checksum mismatch`);
|
|
177
|
+
out.push({ path: name, data });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A relative, forward-slash path that stays inside the archive's root. */
|
|
183
|
+
function safeEntryPath(raw: string): string {
|
|
184
|
+
const p = raw.replaceAll("\\", "/");
|
|
185
|
+
if (p.startsWith("/") || /^[a-zA-Z]:/.test(p) || p.includes("\0")) throw new ZipError(`unsafe path: ${raw}`);
|
|
186
|
+
const parts = p.split("/").filter((s) => s !== "" && s !== ".");
|
|
187
|
+
if (parts.length === 0 || parts.some((s) => s === "..")) throw new ZipError(`unsafe path: ${raw}`);
|
|
188
|
+
return parts.join("/");
|
|
189
|
+
}
|