deepdebug-local-agent 1.12.0 → 1.12.1
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/package.json
CHANGED
|
@@ -15,20 +15,20 @@ const KEEP_ENV = Object.freeze(["PATH", "HOME", "USERPROFILE", "LANG", "TMPDIR",
|
|
|
15
15
|
"XDG_CONFIG_HOME", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", "GH_CONFIG_DIR", "GH_HOST"]);
|
|
16
16
|
|
|
17
17
|
/** Minimal env for git: the names above from this process (what a credential helper needs), never prompts, never the agent's own secrets. */
|
|
18
|
-
export function gitEnv(env = process.env, { askpass = null, tokenFile = null } = {}) {
|
|
18
|
+
export function gitEnv(env = process.env, { askpass = null, tokenFile = null, host = null } = {}) {
|
|
19
19
|
const out = {};
|
|
20
20
|
for (const k of KEEP_ENV) if (env[k] !== undefined) out[k] = env[k];
|
|
21
21
|
out.GIT_TERMINAL_PROMPT = "0";
|
|
22
22
|
out.GCM_INTERACTIVE = "never";
|
|
23
23
|
out.LC_ALL = "C";
|
|
24
|
-
if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.SSH_ASKPASS = askpass; }
|
|
24
|
+
if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.LA_GIT_HOST = host || ""; out.SSH_ASKPASS = askpass; }
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** A 0700 shell script that answers git's askpass questions from a token file: the username is a constant, the password is the file's content. POSIX only. */
|
|
29
29
|
export function ensureAskpassScript(dir) {
|
|
30
30
|
const file = path.join(dir, ".vh-git-askpass.sh");
|
|
31
|
-
const body = "#!/bin/sh\ncase \"$1\" in\n Username*|username*) printf '%s\\n' 'x-access-token' ;;\n *) cat -- \"$LA_GIT_TOKEN_FILE\" ;;\nesac\n";
|
|
31
|
+
const body = "#!/bin/sh\ncase \"$1\" in\n Username*|username*) printf '%s\\n' 'x-access-token' ;;\n *) case \"$1\" in *\"//${LA_GIT_HOST}\"*|*\"@${LA_GIT_HOST}\"*) cat -- \"$LA_GIT_TOKEN_FILE\" ;; *) exit 1 ;; esac ;;\nesac\n";
|
|
32
32
|
let current = null;
|
|
33
33
|
try { current = fs.readFileSync(file, "utf8"); } catch { current = null; }
|
|
34
34
|
if (current !== body) { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, body, { mode: 0o700 }); fs.renameSync(tmp, file); }
|
|
@@ -37,7 +37,8 @@ export function ensureAskpassScript(dir) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** The config every git call gets: no exotic transports (a URL is built by us, https only). */
|
|
40
|
-
export const SAFE_GIT_CONFIG = Object.freeze(["-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"
|
|
40
|
+
export const SAFE_GIT_CONFIG = Object.freeze(["-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null",
|
|
41
|
+
"-c", "http.followRedirects=false", "-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=60"]);
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* @param {string[]} args after the safe config
|
|
@@ -88,7 +88,7 @@ export function resolveMaterializeConfig(env = process.env, warn = console.warn)
|
|
|
88
88
|
/** Validates a repoRef and builds { host, org, repo, branch, key, url } or null. The url has NO credentials and is only https. */
|
|
89
89
|
export function repoTarget(repoRef) {
|
|
90
90
|
if (!repoRef || typeof repoRef !== "object") return null;
|
|
91
|
-
const host = HOSTS[repoRef.provider];
|
|
91
|
+
const host = typeof repoRef.provider === "string" && Object.hasOwn(HOSTS, repoRef.provider) ? HOSTS[repoRef.provider] : null;
|
|
92
92
|
const { org, repo, branch } = repoRef;
|
|
93
93
|
if (!host || typeof org !== "string" || typeof repo !== "string" || typeof branch !== "string") return null;
|
|
94
94
|
if (!SEG.test(org) || !SEG.test(repo) || org === "." || org === ".." || repo === "." || repo === "..") return null;
|
|
@@ -128,7 +128,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
128
128
|
const tenant = String(tenantId).toLowerCase();
|
|
129
129
|
const git = seams.runGit || runGit;
|
|
130
130
|
const stats = { admitted: 0, refused: {}, cloned: 0, reused: 0, failed: {} };
|
|
131
|
-
const inflight = new Map(); // workspaceId -> Promise<{root}
|
|
131
|
+
const inflight = new Map(); // workspaceId -> { p:Promise<{root}>, ctl, waiters }
|
|
132
132
|
let chain = Promise.resolve(); // one clone/fetch at a time on this agent
|
|
133
133
|
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
134
134
|
const refuse = (why, extra) => { stats.refused[why] = (stats.refused[why] || 0) + 1; note("workspace_refused", { why, ...extra }); return null; };
|
|
@@ -179,7 +179,7 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
179
179
|
if (!tokenUsable()) fail("credential_unavailable");
|
|
180
180
|
try { askpass = ensureAskpassScript(root); } catch { fail("cache_unavailable"); }
|
|
181
181
|
}
|
|
182
|
-
const env2 = gitEnv(env, { askpass, tokenFile: config.tokenFile });
|
|
182
|
+
const env2 = { ...gitEnv(env, { askpass, tokenFile: config.tokenFile, host: t.host }), GIT_CEILING_DIRECTORIES: root }; // git never discovers a repository above the cache
|
|
183
183
|
const extraConfig = seams.gitConfig || [];
|
|
184
184
|
const remote = seams.remoteUrl ? seams.remoteUrl(t) : t.url;
|
|
185
185
|
let st = null;
|
|
@@ -189,6 +189,9 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
189
189
|
const sc = readWorkspaceSidecar(dir);
|
|
190
190
|
if (!sc || sc.id !== workspaceId) fail("foreign_directory");
|
|
191
191
|
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) fail("tenant_mismatch");
|
|
192
|
+
let gitSt = null;
|
|
193
|
+
try { gitSt = fs.lstatSync(path.join(dir, ".git")); } catch { gitSt = null; }
|
|
194
|
+
if (!gitSt || !gitSt.isDirectory()) fail("not_a_clone"); // a multi-repo parent or non-git workspace: never fetch/checkout/merge in whatever repo is above it
|
|
192
195
|
// existing clone: fetch the branch; move only a CLEAN tree (the previous job's local work is never reset)
|
|
193
196
|
const f = await git(["fetch", "--depth", String(config.depth), "--no-tags", "--", "origin", t.branch], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
194
197
|
if (!f.ok) fail(f.code === "failed" ? "fetch_failed" : f.code);
|
|
@@ -202,10 +205,21 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
202
205
|
const tmp = path.join(root, `.tmp-${workspaceId}-${crypto.randomBytes(4).toString("hex")}`);
|
|
203
206
|
let ok = false;
|
|
204
207
|
try {
|
|
205
|
-
|
|
208
|
+
// the size limit is enforced WHILE cloning (not only after): a watcher aborts git as soon as the partial clone passes the limit
|
|
209
|
+
const guard = new AbortController();
|
|
210
|
+
const onAbort = () => guard.abort();
|
|
211
|
+
if (signal) { if (signal.aborted) guard.abort(); else signal.addEventListener("abort", onAbort, { once: true }); }
|
|
212
|
+
let over = false;
|
|
213
|
+
const watch = setInterval(() => { if (dirBytes(tmp, config.maxBytes) > config.maxBytes) { over = true; guard.abort(); } }, 1000);
|
|
214
|
+
let c;
|
|
215
|
+
try { c = await git(["clone", "--branch", t.branch, "--single-branch", "--depth", String(config.depth), "--no-tags", "--", remote, tmp], { env: env2, cwd: root, timeoutMs: config.cloneTimeoutMs, signal: guard.signal, extraConfig }); }
|
|
216
|
+
finally { clearInterval(watch); if (signal) signal.removeEventListener("abort", onAbort); }
|
|
217
|
+
if (over) fail("too_large");
|
|
206
218
|
if (!c.ok) fail(c.code === "failed" ? "clone_failed" : c.code);
|
|
207
219
|
if (dirBytes(tmp, config.maxBytes) > config.maxBytes) fail("too_large");
|
|
208
220
|
// the remote of the clone is the credential-free URL we built; nothing else was ever written to its config
|
|
221
|
+
// a repository may ship names that wsManager.open() writes through (a symlink at `.vh-workspace.json*`): drop them before registering
|
|
222
|
+
for (const n of fs.readdirSync(tmp)) if (n.startsWith(".vh-workspace.json")) fs.rmSync(path.join(tmp, n), { recursive: true, force: true });
|
|
209
223
|
fs.renameSync(tmp, dir);
|
|
210
224
|
ok = true;
|
|
211
225
|
} finally {
|
|
@@ -231,14 +245,23 @@ export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit
|
|
|
231
245
|
* One clone at a time; a second call for the same id shares the first one's promise.
|
|
232
246
|
*/
|
|
233
247
|
ensure(workspaceId, repoRef, signal) {
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
let shared = inflight.get(workspaceId);
|
|
249
|
+
if (!shared) {
|
|
250
|
+
// the clone has its OWN controller: it is aborted only when EVERY job waiting for it has been cancelled
|
|
251
|
+
const ctl = new AbortController();
|
|
252
|
+
const p = chain.then(() => { if (ctl.signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, ctl.signal); });
|
|
253
|
+
chain = p.catch(() => {});
|
|
254
|
+
shared = { p, ctl, waiters: 0 };
|
|
255
|
+
inflight.set(workspaceId, shared);
|
|
256
|
+
const clear = () => { if (inflight.get(workspaceId) === shared) inflight.delete(workspaceId); };
|
|
257
|
+
p.then(clear, clear);
|
|
258
|
+
}
|
|
259
|
+
shared.waiters++;
|
|
260
|
+
if (signal) {
|
|
261
|
+
const left = () => { if (--shared.waiters <= 0) shared.ctl.abort(); };
|
|
262
|
+
if (signal.aborted) left(); else signal.addEventListener("abort", left, { once: true });
|
|
263
|
+
}
|
|
264
|
+
return shared.p;
|
|
242
265
|
},
|
|
243
266
|
};
|
|
244
267
|
}
|
package/src/workspace-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import crypto from "crypto";
|
|
3
4
|
import { exists, listRecursive, readFile, stat } from "./fs-utils.js";
|
|
4
5
|
import { WORKSPACE_SIDECAR, readWorkspaceSidecar } from "./utils/open-gate.js";
|
|
5
6
|
import { detectProject } from "./detectors.js";
|
|
@@ -87,13 +88,13 @@ export class WorkspaceManager {
|
|
|
87
88
|
try {
|
|
88
89
|
// Atomic (temp + rename) and owner-only: the sidecar's tenantId is
|
|
89
90
|
// read by ROOT-RECOVERY, so it must never be observed half-written.
|
|
90
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
91
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
91
92
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
92
93
|
id: state.id,
|
|
93
94
|
tenantId: state.tenantId || null,
|
|
94
95
|
root: state.root,
|
|
95
96
|
openedAt: state.openedAt
|
|
96
|
-
}, null, 2) + "\n", { mode: 0o600 });
|
|
97
|
+
}, null, 2) + "\n", { mode: 0o600, flag: "wx" }); // wx: never write through a pre-existing file/symlink
|
|
97
98
|
fs.renameSync(tmp, file);
|
|
98
99
|
// Single-repo clones ARE git repos and the PR path runs `git add -A`:
|
|
99
100
|
// keep the sidecar out of git via .git/info/exclude (never touches
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import http from "node:http";
|
|
4
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
5
|
+
import { createWorkspaceMaterializer, resolveMaterializeConfig, repoTarget } from "../../src/la-protocol-v2/jobs/workspace-materializer.js";
|
|
6
|
+
import { WorkspaceManager } from "../../src/workspace-manager.js";
|
|
7
|
+
const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
8
|
+
const WS = "11111111-2222-4333-8444-555555555555";
|
|
9
|
+
const REF = { provider: "github", org: "acme", repo: "app", branch: "main" };
|
|
10
|
+
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `hos-${p}-`));
|
|
11
|
+
const git = (cwd, ...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
function mk(files = (w) => fs.writeFileSync(path.join(w, "a.txt"), "one\n")) {
|
|
13
|
+
const bare = tmp("remote"), work = tmp("seed");
|
|
14
|
+
git(bare, "init", "--bare", "-b", "main", "."); git(work, "init", "-b", "main", ".");
|
|
15
|
+
files(work); git(work, "add", "-A"); git(work, "commit", "-m", "one"); git(work, "remote", "add", "origin", bare); git(work, "push", "origin", "main");
|
|
16
|
+
return { bare, work };
|
|
17
|
+
}
|
|
18
|
+
function setup({ remote, cacheDir, env = {}, runGitSeam, allow = "github.com/acme/app" } = {}) {
|
|
19
|
+
const cache = cacheDir || tmp("cache");
|
|
20
|
+
const cfg = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true", LOCAL_AGENT_ALLOWED_REPOS: allow, LOCAL_AGENT_WORKSPACE_CACHE: cache, ...env }, () => {});
|
|
21
|
+
const wsManager = new WorkspaceManager();
|
|
22
|
+
const seams = { remoteUrl: () => remote, gitConfig: ["-c", "protocol.file.allow=always"] };
|
|
23
|
+
if (runGitSeam) seams.runGit = runGitSeam;
|
|
24
|
+
const mat = createWorkspaceMaterializer({ config: cfg, tenantId: TENANT, wsManager, audit: { record() {} }, env: { PATH: process.env.PATH, HOME: process.env.HOME }, seams });
|
|
25
|
+
return { mat, cache, wsManager, cfg };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
test("hostile repo: a symlink planted at the sidecar temp name never makes open() overwrite a file outside the clone", async () => {
|
|
31
|
+
const victim = path.join(tmp("victim"), "important.txt"); fs.writeFileSync(victim, "KEEP ME\n");
|
|
32
|
+
const r = mk((w) => { fs.writeFileSync(path.join(w, "a.txt"), "x\n"); fs.symlinkSync(victim, path.join(w, `.vh-workspace.json.${process.pid}.tmp`)); });
|
|
33
|
+
const { mat } = setup({ remote: r.bare });
|
|
34
|
+
assert.equal(mat.admit(WS, REF), true);
|
|
35
|
+
await mat.ensure(WS, REF);
|
|
36
|
+
const after = fs.readFileSync(victim, "utf8");
|
|
37
|
+
console.log("VICTIM AFTER:", JSON.stringify(after.slice(0, 80)));
|
|
38
|
+
assert.equal(after, "KEEP ME\n", "victim file was clobbered through repo-planted symlink");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("existing dir with a sidecar but no .git under an ancestor git repository: refused, git never touches the ancestor", async () => {
|
|
42
|
+
const home = tmp("fakehome"); // acts as $HOME dotfiles repo
|
|
43
|
+
const up = mk(); // upstream of the "dotfiles" repo with branch main
|
|
44
|
+
git(home, "init", "-b", "main", "."); fs.writeFileSync(path.join(home, ".zshrc"), "a\n"); git(home, "add", "-A"); git(home, "commit", "-m", "d");
|
|
45
|
+
git(home, "remote", "add", "origin", up.bare); git(home, "fetch", "origin");
|
|
46
|
+
git(up.work, "checkout", "-b", "evil"); fs.writeFileSync(path.join(up.work, "PLANTED"), "p\n"); git(up.work, "add", "-A"); git(up.work, "commit", "-m", "evil"); git(up.work, "push", "origin", "evil");
|
|
47
|
+
const cache = path.join(home, ".deepdebug", "workspaces"); fs.mkdirSync(cache, { recursive: true });
|
|
48
|
+
fs.writeFileSync(path.join(home, ".gitignore"), ".deepdebug/\n"); git(home, "add", ".gitignore"); git(home, "commit", "-m", "ig");
|
|
49
|
+
const { mat, wsManager } = setup({ remote: "unused", cacheDir: cache });
|
|
50
|
+
const dir = path.join(fs.realpathSync(cache), WS); fs.mkdirSync(dir);
|
|
51
|
+
await wsManager.open(WS, dir, { tenantId: TENANT }); wsManager.close(WS); // leaves no sidecar; write one manually
|
|
52
|
+
wsManager.workspaces.clear(); fs.writeFileSync(path.join(dir, ".vh-workspace.json"), JSON.stringify({ id: WS, tenantId: TENANT, root: dir }));
|
|
53
|
+
const before = git(home, "rev-parse", "HEAD").trim();
|
|
54
|
+
await assert.rejects(mat.ensure(WS, { ...REF, branch: "evil" }), (e) => e.code === "not_a_clone");
|
|
55
|
+
const afterHead = git(home, "rev-parse", "HEAD").trim();
|
|
56
|
+
console.log("ancestor HEAD moved:", before !== afterHead, "FETCH_HEAD exists:", fs.existsSync(path.join(home, ".git", "FETCH_HEAD")), "PLANTED in ancestor:", fs.existsSync(path.join(home, "PLANTED")));
|
|
57
|
+
assert.equal(afterHead, before); assert.ok(!fs.existsSync(path.join(home, "PLANTED")), "git operated on the ancestor repository");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("cancel of one job does not abort another job waiting for the same clone", async () => {
|
|
61
|
+
let calls = 0;
|
|
62
|
+
const slow = (args, o) => new Promise((res) => { calls++; if (o.signal) o.signal.addEventListener("abort", () => res({ ok: false, code: "aborted" })); setTimeout(() => res({ ok: false, code: "failed" }), 3000); });
|
|
63
|
+
const { mat } = setup({ remote: "x", runGitSeam: slow });
|
|
64
|
+
const ac1 = new AbortController(), ac2 = new AbortController();
|
|
65
|
+
const p1 = mat.ensure(WS, REF, ac1.signal).catch((e) => e.code);
|
|
66
|
+
const p2 = mat.ensure(WS, REF, ac2.signal).catch((e) => e.code);
|
|
67
|
+
await new Promise((r) => setTimeout(r, 100)); ac1.abort();
|
|
68
|
+
console.log("job1:", await p1, "job2 (never cancelled):", await p2);
|
|
69
|
+
assert.notEqual(await p2, "aborted");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
test("a redirect never makes git send the token to another host", async () => {
|
|
74
|
+
const seen = [];
|
|
75
|
+
const evil = http.createServer((q, s) => { seen.push({ host: "evil", auth: q.headers.authorization || null, url: q.url }); if (!q.headers.authorization) { s.writeHead(401, { "WWW-Authenticate": 'Basic realm="x"' }); s.end(); } else { s.writeHead(404); s.end(); } }).listen(0);
|
|
76
|
+
await new Promise((r) => evil.once("listening", r));
|
|
77
|
+
const good = http.createServer((q, s) => { seen.push({ host: "good", auth: q.headers.authorization || null, url: q.url }); s.writeHead(302, { Location: `http://127.0.0.1:${evil.address().port}${q.url}` }); s.end(); }).listen(0);
|
|
78
|
+
await new Promise((r) => good.once("listening", r));
|
|
79
|
+
const tokf = path.join(tmp("tok"), "t"); fs.writeFileSync(tokf, "SECRETTOKEN123\n", { mode: 0o600 });
|
|
80
|
+
const { mat } = setup({ remote: `http://127.0.0.1:${good.address().port}/acme/app.git`, env: { LOCAL_AGENT_GIT_TOKEN_FILE: tokf } });
|
|
81
|
+
try { await mat.ensure(WS, REF); } catch (e) { console.log("ensure:", e.code); }
|
|
82
|
+
console.log("SEEN:", JSON.stringify(seen.map((s) => ({ ...s, auth: s.auth ? "PRESENT(" + Buffer.from(s.auth.split(" ")[1], "base64").toString().replace(/:.*/, ":***") + ")" : null }))));
|
|
83
|
+
good.close(); evil.close();
|
|
84
|
+
assert.ok(!seen.some((s) => s.host === "evil" && s.auth), "token sent to redirect target");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
test("repoTarget: provider is an own key of the closed list (no prototype keys)", () => {
|
|
89
|
+
for (const p of ["constructor", "__proto__", "toString", "hasOwnProperty"]) assert.equal(repoTarget({ ...REF, provider: p }), null, p);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("size limit is enforced WHILE cloning: the clone is aborted once the partial clone passes the limit", async () => {
|
|
93
|
+
let aborted = false;
|
|
94
|
+
const fake = (args, o) => new Promise((res) => {
|
|
95
|
+
const tmpDir = args[args.length - 1]; fs.mkdirSync(tmpDir, { recursive: true });
|
|
96
|
+
const t = setInterval(() => fs.appendFileSync(path.join(tmpDir, "blob"), Buffer.alloc(512 * 1024)), 100);
|
|
97
|
+
const stop = (c) => { clearInterval(t); res({ ok: false, code: c }); };
|
|
98
|
+
o.signal.addEventListener("abort", () => { aborted = true; stop("aborted"); });
|
|
99
|
+
setTimeout(() => stop("failed"), 15000);
|
|
100
|
+
});
|
|
101
|
+
const { mat, cache } = setup({ remote: "x", runGitSeam: fake, env: { LOCAL_AGENT_WORKSPACE_MAX_BYTES: String(1024 * 1024) } });
|
|
102
|
+
await assert.rejects(mat.ensure(WS, REF), (e) => e.code === "too_large");
|
|
103
|
+
assert.ok(aborted);
|
|
104
|
+
assert.deepEqual(fs.readdirSync(cache), []);
|
|
105
|
+
});
|