deepdebug-local-agent 1.11.0 → 1.12.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/.github/workflows/build-byoc-image.yml +9 -11
- package/cloudbuild-full.yaml +2 -1
- package/docs/byoc-agent-image.md +8 -7
- package/docs/la-jobs-and-llm.md +7 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/jobs/git-exec.js +71 -0
- package/src/la-protocol-v2/jobs/install.js +5 -1
- package/src/la-protocol-v2/jobs/manager.js +13 -4
- package/src/la-protocol-v2/jobs/workspace-materializer.js +244 -0
- package/test/la-protocol-v2/workspace-materializer.test.js +341 -0
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
# (ideally `:<semver>@sha256:<digest>`); this workflow never moves a floating
|
|
11
11
|
# tag (no :latest, no :v3) and refuses to overwrite an existing :<semver>.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
13
|
+
# Trigger: workflow_dispatch from main only (version defaults to package.json).
|
|
14
|
+
# The CI identity (deploy-byoc-image@visionhammer, vh-infra live/nonprod/_project) trusts
|
|
15
|
+
# this repository at refs/heads/main only. v* tag pushes and release events carry a
|
|
16
|
+
# refs/tags/* ref and would be refused by Workload Identity Federation, so they are no
|
|
17
|
+
# longer triggers (tags pushed by npm-publish.yml's auto-bump never triggered this
|
|
18
|
+
# workflow anyway: they use GITHUB_TOKEN).
|
|
18
19
|
#
|
|
19
20
|
# Supply chain (ADR-0017 D12), OFF by default: the `supply-chain` job signs the
|
|
20
21
|
# pushed image with cosign (keyless, GitHub OIDC), attaches an SPDX SBOM (syft)
|
|
@@ -25,10 +26,6 @@
|
|
|
25
26
|
name: Build BYOC Agent Image
|
|
26
27
|
|
|
27
28
|
on:
|
|
28
|
-
push:
|
|
29
|
-
tags: ['v*']
|
|
30
|
-
release:
|
|
31
|
-
types: [published]
|
|
32
29
|
workflow_dispatch:
|
|
33
30
|
inputs:
|
|
34
31
|
version:
|
|
@@ -52,7 +49,8 @@ permissions:
|
|
|
52
49
|
env:
|
|
53
50
|
GCP_PROJECT: visionhammer
|
|
54
51
|
WIF_PROVIDER: projects/1009366917257/locations/global/workloadIdentityPools/github-pool/providers/github-provider
|
|
55
|
-
|
|
52
|
+
# Per-service identity (module cicd-identity): writer on visionhammer-docker-public-eu only.
|
|
53
|
+
DEPLOYER_SA: deploy-byoc-image@visionhammer.iam.gserviceaccount.com
|
|
56
54
|
AR_HOST: europe-west1-docker.pkg.dev
|
|
57
55
|
AR_IMAGE: europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-public-eu/local-agent-full
|
|
58
56
|
|
|
@@ -200,7 +198,7 @@ jobs:
|
|
|
200
198
|
|
|
201
199
|
# Cosign keyless signature + SBOM + provenance for the image pushed above.
|
|
202
200
|
# Flag: input `supply_chain` (dispatch) or repo variable BYOC_SUPPLY_CHAIN=='true'.
|
|
203
|
-
# Needs
|
|
201
|
+
# Needs deploy-byoc-image to write signatures next to the image (same repository,
|
|
204
202
|
# roles/artifactregistry.writer, already required to push).
|
|
205
203
|
supply-chain:
|
|
206
204
|
needs: build
|
package/cloudbuild-full.yaml
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Usage (from a clean checkout of main; both substitutions are required):
|
|
7
7
|
# gcloud builds submit --project visionhammer --config cloudbuild-full.yaml \
|
|
8
|
+
# --region=europe-west1 --gcs-source-staging-dir=gs://visionhammer-codescan-src-eu/cloudbuild-staging \
|
|
8
9
|
# --substitutions=_VERSION=1.0.31,_GIT_SHA=$(git rev-parse HEAD) .
|
|
9
10
|
#
|
|
10
11
|
# Pushes :<_VERSION> and :<_GIT_SHA> — never a floating tag. Pin the gateway to
|
|
11
12
|
# the resulting digest (deepdebug.byoc.agent-image in vh-api-gateway).
|
|
12
13
|
substitutions:
|
|
13
|
-
_IMAGE: '
|
|
14
|
+
_IMAGE: 'europe-west1-docker.pkg.dev/visionhammer/visionhammer-docker-public-eu/local-agent-full'
|
|
14
15
|
_VERSION: ''
|
|
15
16
|
_GIT_SHA: ''
|
|
16
17
|
|
package/docs/byoc-agent-image.md
CHANGED
|
@@ -14,8 +14,8 @@ built separately per tenant and does not use this image.
|
|
|
14
14
|
## Publishing a version
|
|
15
15
|
|
|
16
16
|
1. Actions → **Build BYOC Agent Image** → *Run workflow* on `main` (version
|
|
17
|
-
defaults to `package.json
|
|
18
|
-
|
|
17
|
+
defaults to `package.json`). Tag pushes and releases no longer trigger it
|
|
18
|
+
(the CI identity only trusts `refs/heads/main`).
|
|
19
19
|
2. The workflow builds `Dockerfile.full`, smoke-tests it (`/health` public,
|
|
20
20
|
API returns 401 without `X-Agent-Token` when `LOCAL_AGENT_TOKEN` is set),
|
|
21
21
|
then pushes `:<semver>` and `:<git-sha>`. It refuses to overwrite an
|
|
@@ -26,8 +26,8 @@ built separately per tenant and does not use this image.
|
|
|
26
26
|
(`...local-agent-full:<semver>@sha256:<digest>`).
|
|
27
27
|
|
|
28
28
|
4. Optional supply chain (flag, off by default): tick `supply_chain` on the
|
|
29
|
-
dispatch (or set the repository variable `BYOC_SUPPLY_CHAIN=true`
|
|
30
|
-
|
|
29
|
+
dispatch (or set the repository variable `BYOC_SUPPLY_CHAIN=true` to sign
|
|
30
|
+
every run). The `supply-chain` job signs `:<semver>@sha256:<digest>` with
|
|
31
31
|
cosign keyless (GitHub OIDC), attaches an SPDX SBOM (syft) and a SLSA v1
|
|
32
32
|
provenance attestation, and verifies all three. Consumers verify with:
|
|
33
33
|
|
|
@@ -63,8 +63,9 @@ container groups created before the TLS/token spec are recreated on re-provision
|
|
|
63
63
|
|
|
64
64
|
## One-time operator setup
|
|
65
65
|
|
|
66
|
-
- `
|
|
67
|
-
`roles/artifactregistry.writer` on the `visionhammer-docker-public-eu` repository (europe-west1
|
|
68
|
-
|
|
66
|
+
- `deploy-byoc-image@visionhammer.iam.gserviceaccount.com` (vh-infra `live/nonprod/_project`, module `cicd-identity`) has
|
|
67
|
+
`roles/artifactregistry.writer` on the `visionhammer-docker-public-eu` repository (europe-west1) and nothing else; writer
|
|
68
|
+
includes the read needed by the overwrite guard. It trusts this repository at `refs/heads/main` only, so the build is a
|
|
69
|
+
`workflow_dispatch` from main (tag and release triggers were removed for that reason).
|
|
69
70
|
- The repository must stay public-read (`allUsers` → `artifactregistry.reader`) so
|
|
70
71
|
customer projects / ACI pull without credentials.
|
package/docs/la-jobs-and-llm.md
CHANGED
|
@@ -60,3 +60,10 @@ Reference runner `LOCAL_AGENT_JOBS_RUNNER=smoke`: reads the first `inputs[].ref`
|
|
|
60
60
|
## Git credentials
|
|
61
61
|
The control plane never sends a Git token: `job.assign` is a closed schema with no field for one (a test sends one and gets `schema_violation`). A runner that opens a PR uses what the machine already has: the
|
|
62
62
|
user's git credential helper, ssh-agent, or variables of the agent's own process environment. The `repoRef` only names provider/org/repo/branch.
|
|
63
|
+
|
|
64
|
+
## Workspace materialization (LA-2, flag `LOCAL_AGENT_JOBS_AUTO_WORKSPACE`, default OFF)
|
|
65
|
+
A `job.assign` names a workspace by id plus `repoRef`. When this agent does not serve that id, it can clone it itself into `LOCAL_AGENT_WORKSPACE_CACHE` (default `~/.deepdebug/workspaces`),
|
|
66
|
+
with the customer's own credentials (helper / ssh-agent / `GH_TOKEN` of the process, or `LOCAL_AGENT_GIT_TOKEN_FILE` read by a GIT_ASKPASS script). Only for repos in the local allowlist
|
|
67
|
+
`LOCAL_AGENT_ALLOWED_REPOS` (+ optional `LOCAL_AGENT_ALLOWED_REPOS_FILE`; `host/org/repo` or `host/org/*`); empty = nothing is materialized. The allowlist is never taken from the control plane.
|
|
68
|
+
The clone runs inside the job (not in the 5 s ack); failure = job `failed`/`infra`, no git text. One clone at a time, one per workspace id, tenant = the session's. No new HTTP surface.
|
|
69
|
+
Limits: `LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS`, `LOCAL_AGENT_WORKSPACE_GIT_TIMEOUT_MS`, `LOCAL_AGENT_WORKSPACE_MAX_BYTES`, `LOCAL_AGENT_WORKSPACE_CLONE_DEPTH`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-exec.js - the ONE way the workspace materializer runs git (LA-2). `execFile` with an argument array (no shell), an explicit cwd, a timeout, an abort
|
|
3
|
+
* signal, a bounded output buffer and a minimal environment. A credential never travels in an argument, in the URL or in `.git/config`: it comes from what
|
|
4
|
+
* this machine already has (the user's credential helper, ssh-agent, GH_TOKEN of this process) or, optionally, from a local token FILE read by a GIT_ASKPASS
|
|
5
|
+
* script git itself runs (the agent never holds the token in a variable).
|
|
6
|
+
*
|
|
7
|
+
* Nothing of git's output leaves this module except `classifyGitFailure()`: a CODE (auth, not_found, branch_missing, timeout, aborted, failed). The raw stderr
|
|
8
|
+
* can name a URL or a user and is never logged, audited or thrown.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const KEEP_ENV = Object.freeze(["PATH", "HOME", "USERPROFILE", "LANG", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "SSH_AUTH_SOCK", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM",
|
|
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
|
+
|
|
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 } = {}) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const k of KEEP_ENV) if (env[k] !== undefined) out[k] = env[k];
|
|
21
|
+
out.GIT_TERMINAL_PROMPT = "0";
|
|
22
|
+
out.GCM_INTERACTIVE = "never";
|
|
23
|
+
out.LC_ALL = "C";
|
|
24
|
+
if (askpass && tokenFile) { out.GIT_ASKPASS = askpass; out.LA_GIT_TOKEN_FILE = tokenFile; out.SSH_ASKPASS = askpass; }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
export function ensureAskpassScript(dir) {
|
|
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";
|
|
32
|
+
let current = null;
|
|
33
|
+
try { current = fs.readFileSync(file, "utf8"); } catch { current = null; }
|
|
34
|
+
if (current !== body) { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, body, { mode: 0o700 }); fs.renameSync(tmp, file); }
|
|
35
|
+
fs.chmodSync(file, 0o700);
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
|
|
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"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string[]} args after the safe config
|
|
44
|
+
* @param {{cwd?:string, env:object, timeoutMs:number, signal?:AbortSignal, extraConfig?:string[]}} o
|
|
45
|
+
* @returns {Promise<{ok:true, stdout:string} | {ok:false, code:string}>} never throws, never returns stderr
|
|
46
|
+
*/
|
|
47
|
+
export function runGit(args, { cwd, env, timeoutMs, signal, extraConfig = [] }) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
if (signal && signal.aborted) { resolve({ ok: false, code: "aborted" }); return; }
|
|
50
|
+
let child;
|
|
51
|
+
try {
|
|
52
|
+
child = execFile("git", [...SAFE_GIT_CONFIG, ...extraConfig, ...args], { cwd, env, timeout: timeoutMs, signal, maxBuffer: 1024 * 1024, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
53
|
+
if (!err) { resolve({ ok: true, stdout: String(stdout || "") }); return; }
|
|
54
|
+
resolve({ ok: false, code: classifyGitFailure(err, String(stderr || ""), signal) });
|
|
55
|
+
});
|
|
56
|
+
} catch { resolve({ ok: false, code: "failed" }); return; }
|
|
57
|
+
void child;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A failure CODE from the exit state and stderr. stderr is read here and nowhere else. */
|
|
62
|
+
export function classifyGitFailure(err, stderr, signal) {
|
|
63
|
+
if (signal && signal.aborted) return "aborted";
|
|
64
|
+
if (err && (err.killed || err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) return "timeout";
|
|
65
|
+
if (err && err.code === "ENOENT") return "git_missing";
|
|
66
|
+
const s = stderr.toLowerCase();
|
|
67
|
+
if (/authentication failed|could not read username|could not read password|terminal prompts disabled|permission denied \(publickey|invalid username or password|http 40[13]|returned error: 40[13]|access denied|bad credentials/.test(s)) return "auth";
|
|
68
|
+
if (/remote branch .* not found|couldn't find remote ref|could not find remote branch/.test(s)) return "branch_missing";
|
|
69
|
+
if (/repository not found|not found|does not exist|http 404|returned error: 404/.test(s)) return "not_found";
|
|
70
|
+
return "failed";
|
|
71
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
9
9
|
import { createJobManager } from "./manager.js";
|
|
10
10
|
import { createSmokeRunner } from "./smoke-runner.js";
|
|
11
|
+
import { resolveMaterializeConfig, createWorkspaceMaterializer } from "./workspace-materializer.js";
|
|
11
12
|
|
|
12
13
|
export const JOBS_CAPABILITIES = Object.freeze(["job.assign"]);
|
|
13
14
|
|
|
@@ -32,7 +33,10 @@ export function installJobs({ link, tenantId, wsManager, getWorkspaceRoot, confi
|
|
|
32
33
|
const resolveWorkspace = makeWorkspaceResolver({ wsManager, getWorkspaceRoot, tenantId: tenant });
|
|
33
34
|
const theRunner = runner || (config.runner === "smoke" ? createSmokeRunner({ maxFileBytes: config.smokeMaxFileBytes }) : null);
|
|
34
35
|
if (!theRunner) throw new Error("job.assign needs a runner");
|
|
35
|
-
|
|
36
|
+
// LA-2: LOCAL_AGENT_JOBS_AUTO_WORKSPACE (default off) lets this agent clone an allowlisted workspace itself, with the customer's own credentials
|
|
37
|
+
const mat = resolveMaterializeConfig(env, (m) => (log.warn || log.log).call(log, m));
|
|
38
|
+
const materializer = mat.enabled && wsManager ? createWorkspaceMaterializer({ config: mat, tenantId: tenant, wsManager, audit: null, env, log }) : null;
|
|
39
|
+
const manager = createJobManager({ session: link.session, tenantId: tenant, resolveWorkspace, materializer, config, env, llm: link.llm || null,
|
|
36
40
|
readFile: link.content ? link.content.readAllowedFile : null, runner: theRunner, onResult, log, now, ...(memoryUsage ? { memoryUsage } : {}), ...(sleep ? { sleep } : {}) });
|
|
37
41
|
link.session.setHandler("job.assign", (ctx) => manager.handleAssign(ctx));
|
|
38
42
|
link.session.setHandler("job.cancel", (ctx) => manager.handleCancel(ctx));
|
|
@@ -58,12 +58,13 @@ export function validateRunner(r) {
|
|
|
58
58
|
&& (r.supportsOptions === undefined || typeof r.supportsOptions === "boolean");
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function createJobManager({ session, tenantId, resolveWorkspace, config, env = process.env, llm = null, readFile = null, runner = null, onResult = null, audit = null,
|
|
61
|
+
export function createJobManager({ session, tenantId, resolveWorkspace, materializer = null, config, env = process.env, llm = null, readFile = null, runner = null, onResult = null, audit = null,
|
|
62
62
|
log = console, now = Date.now, memoryUsage = () => process.memoryUsage().rss, events = null, sleep }) {
|
|
63
63
|
const tenant = String(tenantId).toLowerCase();
|
|
64
64
|
const auditor = audit || createAudit({ file: config.auditFile, now });
|
|
65
65
|
const ev = events || createJobEvents({ session, config, audit: auditor, now, ...(sleep ? { sleep } : {}) });
|
|
66
66
|
const warn = (m) => (log.warn || log.log).call(log, m);
|
|
67
|
+
if (materializer && typeof materializer.bindAudit === "function") materializer.bindAudit(auditor);
|
|
67
68
|
const stats = { received: 0, accepted: 0, duplicate: 0, queued: 0, rejected: {}, finished: { succeeded: 0, failed: 0, cancelled: 0 }, cancels: 0, cancelInvalid: 0, cancelledBeforeAssign: 0,
|
|
68
69
|
optionsInvalid: 0, optionsUnsupported: 0, abandoned: 0 };
|
|
69
70
|
const active = new Map(); // jobId -> record (queued + running)
|
|
@@ -192,7 +193,10 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
192
193
|
},
|
|
193
194
|
budget: () => ({ tokensLeft: Math.max(0, rec.budget.tokens - tokensUsed(rec)), msLeft: Math.max(0, rec.startedAt + secondsMs - now()) }),
|
|
194
195
|
});
|
|
195
|
-
|
|
196
|
+
// LA-2: a workspace this agent does not serve yet is cloned HERE (inside the job, never inside the 5 s ack), with the customer's own credentials, only for an
|
|
197
|
+
// allowlisted repo; a failure is job `failed`/`infra` (a code, never git's text). The runner then finds the workspace in the manager like any other.
|
|
198
|
+
const prepared = rec.materialize ? Promise.resolve().then(() => materializer.ensure(rec.workspaceId, rec.job.repoRef, rec.ac.signal)) : Promise.resolve();
|
|
199
|
+
prepared.then(() => { if (rec.abortReason || rec.finished) return { outcome: "cancelled" }; return theRunner.start(rec.job, ctx); }).then(
|
|
196
200
|
(outcome) => finish(rec, outcome && typeof outcome === "object" ? outcome : null, null),
|
|
197
201
|
(err) => finish(rec, null, err || new Error("runner")),
|
|
198
202
|
).catch(() => {});
|
|
@@ -218,7 +222,12 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
218
222
|
if (active.has(p.jobId) || recent.has(p.jobId)) { stats.duplicate++; note("job_duplicate", base); return { accept: true }; }
|
|
219
223
|
if (cancelledEarly.has(p.jobId)) { stats.cancelledBeforeAssign++; note("job_assign_after_cancel", base); return { accept: true }; } // the CP already gave this job up: never started
|
|
220
224
|
const ws = typeof ctx.workspaceId === "string" && ctx.workspaceId ? resolveWorkspace(ctx.workspaceId) : null;
|
|
221
|
-
|
|
225
|
+
let materialize = false;
|
|
226
|
+
if (!ws || !ws.root) {
|
|
227
|
+
// LA-2 (flag LOCAL_AGENT_JOBS_AUTO_WORKSPACE, default off): not served here, but it may be materialized: allowlisted repo, usable credential, same tenant
|
|
228
|
+
if (!materializer || !materializer.admit(ctx.workspaceId, p.repoRef)) return reject(base, "unauthorized");
|
|
229
|
+
materialize = true;
|
|
230
|
+
}
|
|
222
231
|
// contract 2.1 `options`: only a runner that declared it handles them; a malformed object (the session's schema check should have caught it) runs nothing
|
|
223
232
|
const opts = normalizeJobOptions(p.options, config);
|
|
224
233
|
if (!opts.ok) { stats.optionsInvalid++; return reject(base, "schema_violation"); }
|
|
@@ -233,7 +242,7 @@ export function createJobManager({ session, tenantId, resolveWorkspace, config,
|
|
|
233
242
|
|
|
234
243
|
const budget = { tokens: Math.min(p.budget.tokens, config.maxTokens), seconds: Math.min(p.budget.seconds, config.maxSeconds) };
|
|
235
244
|
const rec = {
|
|
236
|
-
jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
|
|
245
|
+
jobId: p.jobId, short: short(p.jobId), kind: p.kind, workspaceId: ctx.workspaceId, state: "queued", materialize, phase: "init", iteration: 0, filesRead: 0, filesChanged: 0,
|
|
237
246
|
tokensIn: 0, tokensOut: 0, costUsd: null, waiting: false, acceptedAt: now(), startedAt: 0, ac: new AbortController(), abortReason: null, finished: false, ev: null, holdsSlot: false,
|
|
238
247
|
reservedBytes: need, deadlineTimer: null, graceTimer: null, budget,
|
|
239
248
|
job: Object.freeze({ jobId: p.jobId, kind: p.kind, workspaceId: ctx.workspaceId, incidentRef: p.incidentRef, repoRef: p.repoRef, policy: p.policy, budget: Object.freeze({ ...budget }), llm: p.llm, inputs: p.inputs || [], options: opts.options }),
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-materializer.js - LA-2 (ADR-0014 Design A, chat on the Local Agent). A `job.assign` names a workspace by id and a `repoRef`
|
|
3
|
+
* {provider, org, repo, branch}. When this agent does not serve that workspace yet, it can CLONE IT ITSELF, into its own cache, with the customer's OWN
|
|
4
|
+
* credentials (credential helper / ssh-agent / GH_TOKEN of this process, or a local token file read by GIT_ASKPASS). The platform never sends a token and
|
|
5
|
+
* never sees one; it only names the repository.
|
|
6
|
+
*
|
|
7
|
+
* LOCAL_AGENT_JOBS_AUTO_WORKSPACE=true (default OFF) turns it on. Needs LOCAL_AGENT_JOBS (the manager that calls it).
|
|
8
|
+
* LOCAL_AGENT_ALLOWED_REPOS `host/org/repo` or `host/org/*`, separated by comma/space/newline; LOCAL_AGENT_ALLOWED_REPOS_FILE adds a file
|
|
9
|
+
* (one entry per line, `#` comments). ONLY the customer decides this list: it is never taken from the control plane.
|
|
10
|
+
* Empty = nothing is materialized (fail closed).
|
|
11
|
+
* LOCAL_AGENT_WORKSPACE_CACHE where clones live (default ~/.deepdebug/workspaces)
|
|
12
|
+
* LOCAL_AGENT_GIT_TOKEN_FILE optional: a file with a token that a GIT_ASKPASS script cats (POSIX). Absent = the machine's own helper/ssh.
|
|
13
|
+
* LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS (default 300000), LOCAL_AGENT_WORKSPACE_MAX_BYTES (default 2 GiB, a clone above it is removed)
|
|
14
|
+
*
|
|
15
|
+
* Rules: one clone at a time on this agent and one clone per workspace id (a second job for the same id awaits the first); URL built here from the closed
|
|
16
|
+
* provider list and the validated org/repo (never from outside text, never with credentials); `execFile` with an array; the tenant is the SESSION's
|
|
17
|
+
* (wsManager.open(.., {tenantId})); a directory of another tenant, a symlink in the cache or a foreign directory is refused; nothing of git's stderr, of a path
|
|
18
|
+
* or of the allowlist is thrown, logged or audited (codes and short hashes only). An existing clone is fetched and fast-forwarded only when clean: local work
|
|
19
|
+
* of a previous job is never reset.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { readWorkspaceSidecar, isSafeToRemove } from "../../utils/open-gate.js";
|
|
26
|
+
import { short } from "../content-handlers/audit.js";
|
|
27
|
+
import { gitEnv, ensureAskpassScript, runGit } from "./git-exec.js";
|
|
28
|
+
|
|
29
|
+
export const MATERIALIZE_FLAG = "LOCAL_AGENT_JOBS_AUTO_WORKSPACE";
|
|
30
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
31
|
+
const MIB = 1024 * 1024;
|
|
32
|
+
const WS_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
33
|
+
const SEG = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
34
|
+
const BRANCH = /^[A-Za-z0-9_./-]{1,255}$/;
|
|
35
|
+
const HOSTS = Object.freeze({ github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org" }); // azure/other: no known host, not materializable
|
|
36
|
+
const ENTRY = /^([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+|\*)$/;
|
|
37
|
+
|
|
38
|
+
function int(raw, def, min, max) { const n = Number.parseInt(String(raw ?? ""), 10); return Number.isFinite(n) && n >= min && n <= max ? n : def; }
|
|
39
|
+
|
|
40
|
+
/** An error with a code and the job error code `infra`; the message is generic (the manager never reads it anyway). */
|
|
41
|
+
export class MaterializeError extends Error {
|
|
42
|
+
constructor(code) { super(`workspace not available (${code})`); this.name = "MaterializeError"; this.code = code; this.jobErrorCode = "infra"; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parses the allowlist text. Returns { exact:Set<"host/org/repo">, orgs:Set<"host/org">, invalid:number } (lower case). */
|
|
46
|
+
export function parseAllowlist(text) {
|
|
47
|
+
const exact = new Set();
|
|
48
|
+
const orgs = new Set();
|
|
49
|
+
let invalid = 0;
|
|
50
|
+
for (const raw of String(text || "").split(/[\s,]+/)) {
|
|
51
|
+
const e = raw.trim().toLowerCase();
|
|
52
|
+
if (!e || e.startsWith("#")) continue;
|
|
53
|
+
const m = ENTRY.exec(e);
|
|
54
|
+
if (!m || m[2] === "." || m[2] === ".." || m[3] === "." || m[3] === "..") { invalid++; continue; }
|
|
55
|
+
if (m[3] === "*") orgs.add(`${m[1]}/${m[2]}`); else exact.add(`${m[1]}/${m[2]}/${m[3]}`);
|
|
56
|
+
}
|
|
57
|
+
return { exact, orgs, invalid };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readAllowlistText(env) {
|
|
61
|
+
let text = String(env.LOCAL_AGENT_ALLOWED_REPOS ?? "");
|
|
62
|
+
const file = String(env.LOCAL_AGENT_ALLOWED_REPOS_FILE ?? "").trim();
|
|
63
|
+
if (file) {
|
|
64
|
+
try { text += "\n" + fs.readFileSync(file, "utf8").split(/\r?\n/).filter((l) => !l.trim().startsWith("#")).join("\n"); } catch { /* unreadable file = no entries from it (fail closed) */ }
|
|
65
|
+
}
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** { enabled:false } unless LOCAL_AGENT_JOBS_AUTO_WORKSPACE is true. */
|
|
70
|
+
export function resolveMaterializeConfig(env = process.env, warn = console.warn) {
|
|
71
|
+
if (!TRUE.has(String(env[MATERIALIZE_FLAG] ?? "").trim().toLowerCase())) return { enabled: false };
|
|
72
|
+
const allow = parseAllowlist(readAllowlistText(env));
|
|
73
|
+
if (allow.invalid) warn(`[la-workspace] ${allow.invalid} invalid LOCAL_AGENT_ALLOWED_REPOS entr${allow.invalid === 1 ? "y" : "ies"} ignored`);
|
|
74
|
+
if (!allow.exact.size && !allow.orgs.size) warn("[la-workspace] LOCAL_AGENT_ALLOWED_REPOS is empty: no workspace will be materialized");
|
|
75
|
+
const tokenFile = String(env.LOCAL_AGENT_GIT_TOKEN_FILE ?? "").trim() || null;
|
|
76
|
+
return {
|
|
77
|
+
enabled: true,
|
|
78
|
+
allow,
|
|
79
|
+
cacheDir: path.resolve(String(env.LOCAL_AGENT_WORKSPACE_CACHE ?? "").trim() || path.join(os.homedir(), ".deepdebug", "workspaces")),
|
|
80
|
+
tokenFile: tokenFile ? path.resolve(tokenFile) : null,
|
|
81
|
+
cloneTimeoutMs: int(env.LOCAL_AGENT_WORKSPACE_CLONE_TIMEOUT_MS, 300_000, 1_000, 3_600_000),
|
|
82
|
+
gitTimeoutMs: int(env.LOCAL_AGENT_WORKSPACE_GIT_TIMEOUT_MS, 60_000, 1_000, 600_000),
|
|
83
|
+
maxBytes: int(env.LOCAL_AGENT_WORKSPACE_MAX_BYTES, 2048 * MIB, MIB, 64 * 1024 * MIB),
|
|
84
|
+
depth: int(env.LOCAL_AGENT_WORKSPACE_CLONE_DEPTH, 50, 1, 10_000),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Validates a repoRef and builds { host, org, repo, branch, key, url } or null. The url has NO credentials and is only https. */
|
|
89
|
+
export function repoTarget(repoRef) {
|
|
90
|
+
if (!repoRef || typeof repoRef !== "object") return null;
|
|
91
|
+
const host = HOSTS[repoRef.provider];
|
|
92
|
+
const { org, repo, branch } = repoRef;
|
|
93
|
+
if (!host || typeof org !== "string" || typeof repo !== "string" || typeof branch !== "string") return null;
|
|
94
|
+
if (!SEG.test(org) || !SEG.test(repo) || org === "." || org === ".." || repo === "." || repo === "..") return null;
|
|
95
|
+
if (!BRANCH.test(branch) || branch.startsWith("-") || branch.startsWith("/") || branch.endsWith("/") || branch.endsWith(".lock") || branch.includes("..") || branch.includes("//") || branch.includes("/.")) return null;
|
|
96
|
+
return { host, org, repo, branch, key: `${host}/${org.toLowerCase()}/${repo.toLowerCase()}`, orgKey: `${host}/${org.toLowerCase()}`, url: `https://${host}/${org}/${repo}.git` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Bounded, symlink-safe size of a directory (lstat, no following). Returns bytes counted up to `limit`+1. */
|
|
100
|
+
function dirBytes(dir, limit) {
|
|
101
|
+
let total = 0;
|
|
102
|
+
const stack = [dir];
|
|
103
|
+
while (stack.length) {
|
|
104
|
+
const d = stack.pop();
|
|
105
|
+
let ents;
|
|
106
|
+
try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
107
|
+
for (const e of ents) {
|
|
108
|
+
const p = path.join(d, e.name);
|
|
109
|
+
if (e.isDirectory()) stack.push(p);
|
|
110
|
+
else { try { total += fs.lstatSync(p).size; } catch { /* vanished */ } }
|
|
111
|
+
if (total > limit) return total;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return total;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} o
|
|
119
|
+
* @param {object} o.config resolveMaterializeConfig() (enabled)
|
|
120
|
+
* @param {string} o.tenantId the SESSION's tenant
|
|
121
|
+
* @param {object} o.wsManager open(id, dir, {tenantId}), list()
|
|
122
|
+
* @param {object} [o.audit] { record(entry) } - codes and short hashes only
|
|
123
|
+
* @param {object} [o.env]
|
|
124
|
+
* @param {object} [o.seams] TESTS ONLY: { remoteUrl(target) => string, gitConfig: string[] (e.g. protocol.file.allow=always), runGit }
|
|
125
|
+
*/
|
|
126
|
+
export function createWorkspaceMaterializer({ config, tenantId, wsManager, audit: auditIn = null, env = process.env, log = console, seams = {} }) {
|
|
127
|
+
let audit = auditIn;
|
|
128
|
+
const tenant = String(tenantId).toLowerCase();
|
|
129
|
+
const git = seams.runGit || runGit;
|
|
130
|
+
const stats = { admitted: 0, refused: {}, cloned: 0, reused: 0, failed: {} };
|
|
131
|
+
const inflight = new Map(); // workspaceId -> Promise<{root}>
|
|
132
|
+
let chain = Promise.resolve(); // one clone/fetch at a time on this agent
|
|
133
|
+
const note = (event, extra) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
|
|
134
|
+
const refuse = (why, extra) => { stats.refused[why] = (stats.refused[why] || 0) + 1; note("workspace_refused", { why, ...extra }); return null; };
|
|
135
|
+
|
|
136
|
+
const cacheReal = () => { try { fs.mkdirSync(config.cacheDir, { recursive: true, mode: 0o700 }); return fs.realpathSync(config.cacheDir); } catch { return null; } };
|
|
137
|
+
const allowed = (t) => config.allow.exact.has(t.key) || config.allow.orgs.has(t.orgKey);
|
|
138
|
+
const tokenUsable = () => { if (!config.tokenFile) return true; try { fs.accessSync(config.tokenFile, fs.constants.R_OK); return fs.statSync(config.tokenFile).isFile(); } catch { return false; } };
|
|
139
|
+
const listed = (id) => (wsManager?.list?.() || []).find((w) => w && w.id === id);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Synchronous admission (inside the 5 s ack): may this workspace be materialized for this job? true = the manager accepts the job and calls ensure()
|
|
143
|
+
* from inside it. false = the job is refused `unauthorized` with an audit CODE. Never touches the network.
|
|
144
|
+
*/
|
|
145
|
+
function admit(workspaceId, repoRef) {
|
|
146
|
+
const base = { workspaceShort: short(workspaceId) };
|
|
147
|
+
if (typeof workspaceId !== "string" || !WS_ID.test(workspaceId) || workspaceId === "default") return !!refuse("bad_workspace_id", base);
|
|
148
|
+
if (listed(workspaceId)) return !!refuse("already_open_elsewhere", base); // served (the resolver decided) or another tenant's: never replaced
|
|
149
|
+
const t = repoTarget(repoRef);
|
|
150
|
+
if (!t) return !!refuse("bad_repo_ref", base);
|
|
151
|
+
if (!allowed(t)) return !!refuse("repo_not_allowed", { ...base, repoShort: short(t.key) });
|
|
152
|
+
if (!tokenUsable()) return !!refuse("credential_unavailable", base); // a configured token file that cannot be read: a clear refusal now, not a failed clone later
|
|
153
|
+
const root = cacheReal();
|
|
154
|
+
if (!root) return !!refuse("cache_unavailable", base);
|
|
155
|
+
const dir = path.join(root, workspaceId);
|
|
156
|
+
let st = null;
|
|
157
|
+
try { st = fs.lstatSync(dir); } catch { st = null; }
|
|
158
|
+
if (st) {
|
|
159
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return !!refuse("cache_entry_unsafe", base);
|
|
160
|
+
const sc = readWorkspaceSidecar(dir);
|
|
161
|
+
if (!sc || sc.id !== workspaceId) return !!refuse("foreign_directory", base);
|
|
162
|
+
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) return !!refuse("tenant_mismatch", base);
|
|
163
|
+
}
|
|
164
|
+
stats.admitted++;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function doEnsure(workspaceId, repoRef, signal) {
|
|
169
|
+
const t = repoTarget(repoRef);
|
|
170
|
+
const ws = short(workspaceId);
|
|
171
|
+
const fail = (code) => { stats.failed[code] = (stats.failed[code] || 0) + 1; note("workspace_failed", { code, workspaceShort: ws }); throw new MaterializeError(code); };
|
|
172
|
+
if (!t || !allowed(t) || !WS_ID.test(workspaceId)) fail("repo_not_allowed"); // defence in depth: ensure() re-checks what admit() checked
|
|
173
|
+
const root = cacheReal();
|
|
174
|
+
if (!root) fail("cache_unavailable");
|
|
175
|
+
const dir = path.join(root, workspaceId);
|
|
176
|
+
if (!isSafeToRemove(root, dir)) fail("cache_entry_unsafe");
|
|
177
|
+
let askpass = null;
|
|
178
|
+
if (config.tokenFile) {
|
|
179
|
+
if (!tokenUsable()) fail("credential_unavailable");
|
|
180
|
+
try { askpass = ensureAskpassScript(root); } catch { fail("cache_unavailable"); }
|
|
181
|
+
}
|
|
182
|
+
const env2 = gitEnv(env, { askpass, tokenFile: config.tokenFile });
|
|
183
|
+
const extraConfig = seams.gitConfig || [];
|
|
184
|
+
const remote = seams.remoteUrl ? seams.remoteUrl(t) : t.url;
|
|
185
|
+
let st = null;
|
|
186
|
+
try { st = fs.lstatSync(dir); } catch { st = null; }
|
|
187
|
+
if (st) {
|
|
188
|
+
if (st.isSymbolicLink() || !st.isDirectory()) fail("cache_entry_unsafe");
|
|
189
|
+
const sc = readWorkspaceSidecar(dir);
|
|
190
|
+
if (!sc || sc.id !== workspaceId) fail("foreign_directory");
|
|
191
|
+
if (!sc.tenantId || sc.tenantId.toLowerCase() !== tenant) fail("tenant_mismatch");
|
|
192
|
+
// existing clone: fetch the branch; move only a CLEAN tree (the previous job's local work is never reset)
|
|
193
|
+
const f = await git(["fetch", "--depth", String(config.depth), "--no-tags", "--", "origin", t.branch], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
194
|
+
if (!f.ok) fail(f.code === "failed" ? "fetch_failed" : f.code);
|
|
195
|
+
const dirty = await git(["status", "--porcelain"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
196
|
+
if (dirty.ok && dirty.stdout.trim() === "") {
|
|
197
|
+
const co = await git(["checkout", "--quiet", t.branch, "--"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
198
|
+
if (co.ok) await git(["merge", "--ff-only", "--quiet", "FETCH_HEAD"], { cwd: dir, env: env2, timeoutMs: config.gitTimeoutMs, signal, extraConfig });
|
|
199
|
+
} else note("workspace_kept_dirty", { workspaceShort: ws });
|
|
200
|
+
stats.reused++;
|
|
201
|
+
} else {
|
|
202
|
+
const tmp = path.join(root, `.tmp-${workspaceId}-${crypto.randomBytes(4).toString("hex")}`);
|
|
203
|
+
let ok = false;
|
|
204
|
+
try {
|
|
205
|
+
const 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, extraConfig });
|
|
206
|
+
if (!c.ok) fail(c.code === "failed" ? "clone_failed" : c.code);
|
|
207
|
+
if (dirBytes(tmp, config.maxBytes) > config.maxBytes) fail("too_large");
|
|
208
|
+
// the remote of the clone is the credential-free URL we built; nothing else was ever written to its config
|
|
209
|
+
fs.renameSync(tmp, dir);
|
|
210
|
+
ok = true;
|
|
211
|
+
} finally {
|
|
212
|
+
if (!ok) { try { if (isSafeToRemove(root, tmp)) fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
213
|
+
}
|
|
214
|
+
stats.cloned++;
|
|
215
|
+
}
|
|
216
|
+
try { await wsManager.open(workspaceId, dir, { tenantId: tenant }); } catch {
|
|
217
|
+
if (!st) { try { if (isSafeToRemove(root, dir)) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } } // a fresh clone that could not be registered must not stay as a "foreign" directory
|
|
218
|
+
fail("open_failed");
|
|
219
|
+
}
|
|
220
|
+
note("workspace_ready", { workspaceShort: ws, repoShort: short(t.key), reused: !!st });
|
|
221
|
+
return { root: dir };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
stats, config,
|
|
226
|
+
/** The job manager hands its audit in (one trail for jobs and workspaces). */
|
|
227
|
+
bindAudit(a) { if (!audit) audit = a; },
|
|
228
|
+
admit,
|
|
229
|
+
/**
|
|
230
|
+
* Clone (or refresh) and register the workspace. Called by the job manager from inside the job; resolves { root } or throws MaterializeError (job `failed`, `infra`).
|
|
231
|
+
* One clone at a time; a second call for the same id shares the first one's promise.
|
|
232
|
+
*/
|
|
233
|
+
ensure(workspaceId, repoRef, signal) {
|
|
234
|
+
const running = inflight.get(workspaceId);
|
|
235
|
+
if (running) return running;
|
|
236
|
+
const p = chain.then(() => { if (signal && signal.aborted) throw new MaterializeError("aborted"); return doEnsure(workspaceId, repoRef, signal); });
|
|
237
|
+
chain = p.catch(() => {});
|
|
238
|
+
inflight.set(workspaceId, p);
|
|
239
|
+
const clear = () => { if (inflight.get(workspaceId) === p) inflight.delete(workspaceId); };
|
|
240
|
+
p.then(clear, clear);
|
|
241
|
+
return p;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// LA-2: the Local Agent materializes a workspace itself for a job.assign (flag LOCAL_AGENT_JOBS_AUTO_WORKSPACE, default off). Allowlist (fail closed),
|
|
2
|
+
// customer-side credentials only, tenant gate, cache safety (symlink, foreign dir), no secret anywhere, one clone at a time, job manager integration.
|
|
3
|
+
// Real git is used against a local bare repository (the test seam allows the file transport; production never does).
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { createWorkspaceMaterializer, resolveMaterializeConfig, parseAllowlist, repoTarget, MaterializeError } from "../../src/la-protocol-v2/jobs/workspace-materializer.js";
|
|
12
|
+
import { classifyGitFailure, gitEnv, ensureAskpassScript, SAFE_GIT_CONFIG } from "../../src/la-protocol-v2/jobs/git-exec.js";
|
|
13
|
+
import { createJobManager } from "../../src/la-protocol-v2/jobs/manager.js";
|
|
14
|
+
import { resolveJobsConfig } from "../../src/la-protocol-v2/jobs/config.js";
|
|
15
|
+
import { createAudit } from "../../src/la-protocol-v2/content-handlers/audit.js";
|
|
16
|
+
import { WorkspaceManager } from "../../src/workspace-manager.js";
|
|
17
|
+
import { WORKSPACE_SIDECAR } from "../../src/utils/open-gate.js";
|
|
18
|
+
|
|
19
|
+
const TENANT = "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
20
|
+
const OTHER = "9a9a9a9a-5a4d-4e7b-9c21-0a1b2c3d4e5f";
|
|
21
|
+
const WS = "11111111-2222-4333-8444-555555555555";
|
|
22
|
+
const SECRET = "ghp_PLANTEDsecretTOKEN0123456789abcdef";
|
|
23
|
+
const REF = { provider: "github", org: "acme", repo: "app", branch: "main" };
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `la-mat-${p}-`));
|
|
26
|
+
const git = (cwd, ...a) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
27
|
+
|
|
28
|
+
/** A bare "remote" with one commit on main (and one on `dev`); returns its path. */
|
|
29
|
+
function makeRemote() {
|
|
30
|
+
const bare = tmp("remote");
|
|
31
|
+
const work = tmp("seed");
|
|
32
|
+
git(bare, "init", "--bare", "-b", "main", ".");
|
|
33
|
+
git(work, "init", "-b", "main", ".");
|
|
34
|
+
fs.writeFileSync(path.join(work, "a.txt"), "one\n");
|
|
35
|
+
git(work, "add", "."); git(work, "commit", "-m", "one");
|
|
36
|
+
git(work, "remote", "add", "origin", bare);
|
|
37
|
+
git(work, "push", "origin", "main");
|
|
38
|
+
return { bare, work, push(text) { fs.writeFileSync(path.join(work, "a.txt"), text); git(work, "commit", "-am", "more"); git(work, "push", "origin", "main"); } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setup({ allow = "github.com/acme/app", remote = null, env = {}, runGit = undefined, tenant = TENANT } = {}) {
|
|
42
|
+
const cache = tmp("cache");
|
|
43
|
+
const warn = [];
|
|
44
|
+
const cfg = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true", LOCAL_AGENT_ALLOWED_REPOS: allow, LOCAL_AGENT_WORKSPACE_CACHE: cache, ...env }, (m) => warn.push(m));
|
|
45
|
+
const wsManager = new WorkspaceManager();
|
|
46
|
+
const audit = createAudit({});
|
|
47
|
+
const seams = remote ? { remoteUrl: () => remote.bare, gitConfig: ["-c", "protocol.file.allow=always"] } : {};
|
|
48
|
+
if (runGit) seams.runGit = runGit;
|
|
49
|
+
const mat = createWorkspaceMaterializer({ config: cfg, tenantId: tenant, wsManager, audit, env: { PATH: process.env.PATH, HOME: process.env.HOME, ...(env.PROC || {}) }, seams });
|
|
50
|
+
return { mat, cfg, cache, wsManager, audit, warn };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- config / parsing
|
|
54
|
+
|
|
55
|
+
test("config: off by default; on needs the flag; an empty allowlist warns and admits nothing", () => {
|
|
56
|
+
for (const v of [undefined, "", "false", "0", "nope"]) assert.deepEqual(resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: v, LOCAL_AGENT_ALLOWED_REPOS: "github.com/acme/app" }), { enabled: false });
|
|
57
|
+
const w = [];
|
|
58
|
+
const c = resolveMaterializeConfig({ LOCAL_AGENT_JOBS_AUTO_WORKSPACE: "true" }, (m) => w.push(m));
|
|
59
|
+
assert.equal(c.enabled, true);
|
|
60
|
+
assert.match(w.join("\n"), /empty/);
|
|
61
|
+
const { mat } = setup({ allow: "" });
|
|
62
|
+
assert.equal(mat.admit(WS, REF), false);
|
|
63
|
+
assert.equal(mat.stats.refused.repo_not_allowed, 1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("allowlist parsing: exact and org/*; garbage (scheme, @, .., bare *, host/*) is dropped", () => {
|
|
67
|
+
const a = parseAllowlist("github.com/Acme/App, github.com/corp/*\nhttps://github.com/x/y git@github.com:x/y.git github.com/../x github.com/o/.. * github.com/* # c");
|
|
68
|
+
assert.deepEqual([...a.exact], ["github.com/acme/app"]);
|
|
69
|
+
assert.deepEqual([...a.orgs], ["github.com/corp"]);
|
|
70
|
+
assert.ok(a.invalid >= 6);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("repoTarget: closed provider list, strict segments, branch rules, no credentials in the URL", () => {
|
|
74
|
+
const ok = repoTarget(REF);
|
|
75
|
+
assert.equal(ok.url, "https://github.com/acme/app.git");
|
|
76
|
+
assert.ok(!/@/.test(ok.url));
|
|
77
|
+
for (const bad of [{ ...REF, org: ".." }, { ...REF, repo: "." }, { ...REF, org: "a/b" }, { ...REF, org: "a@b" }, { ...REF, repo: "x y" }, { ...REF, provider: "azure" }, { ...REF, provider: "other" },
|
|
78
|
+
{ ...REF, provider: "evil.com" }, { ...REF, branch: "-x" }, { ...REF, branch: "a..b" }, { ...REF, branch: "a//b" }, { ...REF, branch: "x.lock" }, { ...REF, branch: "/x" }, { ...REF, branch: "a b" }, null, {}]) assert.equal(repoTarget(bad), null, JSON.stringify(bad));
|
|
79
|
+
assert.equal(repoTarget({ ...REF, provider: "gitlab" }).url, "https://gitlab.com/acme/app.git");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("git-exec: failure classification by code only; env is minimal and never carries the agent's own secrets", () => {
|
|
83
|
+
assert.equal(classifyGitFailure({}, "fatal: Authentication failed for 'https://x/'"), "auth");
|
|
84
|
+
assert.equal(classifyGitFailure({}, "fatal: could not read Username for 'https://github.com': terminal prompts disabled"), "auth");
|
|
85
|
+
assert.equal(classifyGitFailure({}, "fatal: Remote branch zz not found in upstream origin"), "branch_missing");
|
|
86
|
+
assert.equal(classifyGitFailure({}, "remote: Repository not found."), "not_found");
|
|
87
|
+
assert.equal(classifyGitFailure({ killed: true }, ""), "timeout");
|
|
88
|
+
assert.equal(classifyGitFailure({}, "boom"), "failed");
|
|
89
|
+
const ac = new AbortController(); ac.abort();
|
|
90
|
+
assert.equal(classifyGitFailure({}, "", ac.signal), "aborted");
|
|
91
|
+
const e = gitEnv({ PATH: "/bin", HOME: "/h", LOCAL_AGENT_TOKEN: "x", DEEPDEBUG_API_KEY: "y", LOCAL_AGENT_LLM_KEY: "z", SSH_AUTH_SOCK: "/s" });
|
|
92
|
+
assert.deepEqual(Object.keys(e).sort(), ["GCM_INTERACTIVE", "GIT_TERMINAL_PROMPT", "HOME", "LC_ALL", "PATH", "SSH_AUTH_SOCK"]);
|
|
93
|
+
assert.ok(SAFE_GIT_CONFIG.includes("protocol.ext.allow=never") && SAFE_GIT_CONFIG.includes("protocol.file.allow=never"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- admission
|
|
97
|
+
|
|
98
|
+
test("admit: allowlisted repo ok; org/* ok; repo outside the allowlist, bad ids and 'default' are refused with a code", () => {
|
|
99
|
+
const { mat, audit } = setup({ allow: "github.com/acme/app,github.com/corp/*" });
|
|
100
|
+
assert.equal(mat.admit(WS, REF), true);
|
|
101
|
+
assert.equal(mat.admit(WS, { ...REF, org: "corp", repo: "anything" }), true);
|
|
102
|
+
assert.equal(mat.admit(WS, { ...REF, org: "evil" }), false);
|
|
103
|
+
assert.equal(mat.admit(WS, { ...REF, repo: "other" }), false);
|
|
104
|
+
assert.equal(mat.admit(WS, { ...REF, org: "..", repo: ".." }), false);
|
|
105
|
+
for (const id of ["default", "../x", "a/b", "", ".hidden", "x".repeat(101), null, 5]) assert.equal(mat.admit(id, REF), false, String(id));
|
|
106
|
+
assert.equal(mat.admit(WS, null), false);
|
|
107
|
+
assert.ok(mat.stats.refused.repo_not_allowed >= 2);
|
|
108
|
+
assert.ok(mat.stats.refused.bad_workspace_id >= 7);
|
|
109
|
+
const trail = JSON.stringify(audit.tail(100));
|
|
110
|
+
assert.ok(!trail.includes("evil") && !trail.includes(WS), "audit holds short hashes and codes, not names");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("admit: an id already open (here or for another tenant) is never replaced", async () => {
|
|
114
|
+
const { mat, wsManager, cache } = setup();
|
|
115
|
+
const d = path.join(cache, "other"); fs.mkdirSync(d);
|
|
116
|
+
await wsManager.open(WS, d, { tenantId: OTHER });
|
|
117
|
+
assert.equal(mat.admit(WS, REF), false);
|
|
118
|
+
assert.equal(mat.stats.refused.already_open_elsewhere, 1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("admit: a cache entry that is a symlink, a directory without registration, or one of ANOTHER tenant is refused", () => {
|
|
122
|
+
const { mat, cache } = setup();
|
|
123
|
+
const outside = tmp("outside");
|
|
124
|
+
fs.symlinkSync(outside, path.join(cache, WS));
|
|
125
|
+
assert.equal(mat.admit(WS, REF), false);
|
|
126
|
+
assert.equal(mat.stats.refused.cache_entry_unsafe, 1);
|
|
127
|
+
const ws2 = "22222222-2222-4333-8444-555555555555";
|
|
128
|
+
fs.mkdirSync(path.join(cache, ws2));
|
|
129
|
+
assert.equal(mat.admit(ws2, REF), false);
|
|
130
|
+
assert.equal(mat.stats.refused.foreign_directory, 1);
|
|
131
|
+
const ws3 = "33333333-2222-4333-8444-555555555555";
|
|
132
|
+
const d3 = path.join(fs.realpathSync(cache), ws3); fs.mkdirSync(d3);
|
|
133
|
+
fs.writeFileSync(path.join(d3, WORKSPACE_SIDECAR), JSON.stringify({ id: ws3, tenantId: OTHER, root: d3, openedAt: "x" }));
|
|
134
|
+
assert.equal(mat.admit(ws3, REF), false);
|
|
135
|
+
assert.equal(mat.stats.refused.tenant_mismatch, 1);
|
|
136
|
+
assert.deepEqual(fs.readdirSync(outside), [], "nothing was written through the symlink");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("admit: a configured token file that cannot be read is a clear refusal (credential_unavailable); without a token file the machine's own credentials are used", () => {
|
|
140
|
+
const { mat } = setup({ env: { LOCAL_AGENT_GIT_TOKEN_FILE: "/nonexistent/token" } });
|
|
141
|
+
assert.equal(mat.admit(WS, REF), false);
|
|
142
|
+
assert.equal(mat.stats.refused.credential_unavailable, 1);
|
|
143
|
+
assert.equal(setup().mat.admit(WS, REF), true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- real clone against a local bare repository
|
|
147
|
+
|
|
148
|
+
test("ensure: clones into the cache, registers the workspace under the SESSION tenant, leaves no token and no credential in .git/config", async () => {
|
|
149
|
+
const remote = makeRemote();
|
|
150
|
+
const { mat, wsManager, cache, audit } = setup({ remote });
|
|
151
|
+
assert.equal(mat.admit(WS, REF), true);
|
|
152
|
+
const r = await mat.ensure(WS, REF);
|
|
153
|
+
assert.equal(r.root, path.join(fs.realpathSync(cache), WS));
|
|
154
|
+
assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "one\n");
|
|
155
|
+
const w = wsManager.list().find((x) => x.id === WS);
|
|
156
|
+
assert.equal(w.tenantId, TENANT);
|
|
157
|
+
const cfg = fs.readFileSync(path.join(r.root, ".git", "config"), "utf8");
|
|
158
|
+
assert.ok(!/@|token|password|x-access-token/i.test(cfg.replace(/user\.email/g, "")), "config has no credential");
|
|
159
|
+
assert.deepEqual(fs.readdirSync(cache).filter((n) => n.startsWith(".tmp-")), [], "no temp clone left");
|
|
160
|
+
assert.equal(mat.stats.cloned, 1);
|
|
161
|
+
assert.ok(audit.tail().some((e) => e.event === "workspace_ready"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("ensure: second call fetches and fast-forwards a CLEAN clone; a DIRTY one is kept untouched", async () => {
|
|
165
|
+
const remote = makeRemote();
|
|
166
|
+
const { mat } = setup({ remote });
|
|
167
|
+
const r = await mat.ensure(WS, REF);
|
|
168
|
+
remote.push("two\n");
|
|
169
|
+
await mat.ensure(WS, REF);
|
|
170
|
+
assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "two\n");
|
|
171
|
+
fs.writeFileSync(path.join(r.root, "a.txt"), "local work\n");
|
|
172
|
+
remote.push("three\n");
|
|
173
|
+
await mat.ensure(WS, REF);
|
|
174
|
+
assert.equal(fs.readFileSync(path.join(r.root, "a.txt"), "utf8"), "local work\n", "previous job's work is never reset");
|
|
175
|
+
assert.equal(mat.stats.reused, 2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("ensure: a missing branch fails with a code, leaves no directory behind and nothing registered", async () => {
|
|
179
|
+
const remote = makeRemote();
|
|
180
|
+
const { mat, wsManager, cache } = setup({ remote });
|
|
181
|
+
await assert.rejects(() => mat.ensure(WS, { ...REF, branch: "nope" }), (e) => e instanceof MaterializeError && e.code === "branch_missing" && e.jobErrorCode === "infra" && !/nope|acme|bare|remote/.test(e.message));
|
|
182
|
+
assert.deepEqual(fs.readdirSync(cache), []);
|
|
183
|
+
assert.equal(wsManager.list().length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("ensure: concurrent jobs for the same workspace share ONE clone; two workspaces are serialized (one clone at a time)", async () => {
|
|
187
|
+
const remote = makeRemote();
|
|
188
|
+
let active = 0, peak = 0, clones = 0;
|
|
189
|
+
const real = (await import("../../src/la-protocol-v2/jobs/git-exec.js")).runGit;
|
|
190
|
+
const spy = async (args, o) => { const isClone = args[0] === "clone"; if (isClone) { clones++; active++; peak = Math.max(peak, active); await new Promise((r) => setTimeout(r, 40)); } try { return await real(args, o); } finally { if (isClone) active--; } };
|
|
191
|
+
const { mat } = setup({ remote, allow: "github.com/acme/*", runGit: spy });
|
|
192
|
+
const WS2 = "44444444-2222-4333-8444-555555555555";
|
|
193
|
+
await Promise.all([mat.ensure(WS, REF), mat.ensure(WS, REF), mat.ensure(WS2, REF)]);
|
|
194
|
+
assert.equal(clones, 2);
|
|
195
|
+
assert.equal(peak, 1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("ensure: a workspace of another tenant on disk, a symlinked cache entry and a repo outside the allowlist are refused even if admit() was skipped", async () => {
|
|
199
|
+
const remote = makeRemote();
|
|
200
|
+
const { mat, cache } = setup({ remote });
|
|
201
|
+
const outside = tmp("outside");
|
|
202
|
+
fs.symlinkSync(outside, path.join(cache, WS));
|
|
203
|
+
await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "cache_entry_unsafe");
|
|
204
|
+
assert.deepEqual(fs.readdirSync(outside), []);
|
|
205
|
+
await assert.rejects(() => mat.ensure("55555555-2222-4333-8444-555555555555", { ...REF, org: "evil" }), (e) => e.code === "repo_not_allowed");
|
|
206
|
+
const other = setup({ remote, tenant: OTHER });
|
|
207
|
+
const first = await mat.ensure("66666666-2222-4333-8444-555555555555", REF);
|
|
208
|
+
fs.cpSync(first.root, path.join(other.cache, "66666666-2222-4333-8444-555555555555"), { recursive: true }); // same sidecar, tenant TENANT, found by OTHER's agent
|
|
209
|
+
await assert.rejects(() => other.mat.ensure("66666666-2222-4333-8444-555555555555", REF), (e) => e.code === "tenant_mismatch" || e.code === "foreign_directory");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("ensure: a clone above the size limit is removed (too_large); an abort before start does not run git", async () => {
|
|
213
|
+
const remote = makeRemote();
|
|
214
|
+
const { mat, cache } = setup({ remote, env: { LOCAL_AGENT_WORKSPACE_MAX_BYTES: String(1024 * 1024) } });
|
|
215
|
+
fs.writeFileSync(path.join(remote.work, "big.bin"), crypto.randomBytes(1200 * 1024));
|
|
216
|
+
git(remote.work, "add", "."); git(remote.work, "commit", "-m", "big"); git(remote.work, "push", "origin", "main");
|
|
217
|
+
await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "too_large");
|
|
218
|
+
assert.deepEqual(fs.readdirSync(cache), []);
|
|
219
|
+
const ac = new AbortController(); ac.abort();
|
|
220
|
+
await assert.rejects(() => mat.ensure("77777777-2222-4333-8444-555555555555", REF, ac.signal), (e) => e.code === "aborted");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------- secrets
|
|
224
|
+
|
|
225
|
+
test("secrets: the token file is only passed by PATH (askpass), never in args, env values, logs, audit or errors; the askpass script holds no token", async () => {
|
|
226
|
+
const dir = tmp("tok");
|
|
227
|
+
const tokenFile = path.join(dir, "token");
|
|
228
|
+
fs.writeFileSync(tokenFile, SECRET, { mode: 0o600 });
|
|
229
|
+
const calls = [];
|
|
230
|
+
const fake = async (args, o) => { calls.push({ args, env: o.env }); return { ok: false, code: "auth" }; };
|
|
231
|
+
const logs = [];
|
|
232
|
+
const { mat, audit, cache } = setup({ env: { LOCAL_AGENT_GIT_TOKEN_FILE: tokenFile }, runGit: fake });
|
|
233
|
+
await assert.rejects(() => mat.ensure(WS, REF), (e) => e.code === "auth" && !e.message.includes(SECRET));
|
|
234
|
+
const everything = JSON.stringify({ calls, audit: audit.tail(100), logs, stats: mat.stats });
|
|
235
|
+
assert.ok(!everything.includes(SECRET));
|
|
236
|
+
assert.equal(calls[0].env.LA_GIT_TOKEN_FILE, tokenFile);
|
|
237
|
+
assert.ok(calls[0].env.GIT_ASKPASS && calls[0].env.GIT_TERMINAL_PROMPT === "0");
|
|
238
|
+
assert.ok(!calls[0].args.some((a) => /@|ghp_|x-access-token/.test(a)), "no credential in any git argument");
|
|
239
|
+
const script = fs.readFileSync(ensureAskpassScript(fs.realpathSync(cache)), "utf8");
|
|
240
|
+
assert.ok(!script.includes(SECRET));
|
|
241
|
+
assert.equal(fs.statSync(path.join(fs.realpathSync(cache), ".vh-git-askpass.sh")).mode & 0o777, 0o700);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("secrets: with a real git the askpass script answers from the file (user constant, password = file content) without the agent holding it", () => {
|
|
245
|
+
const dir = tmp("ask");
|
|
246
|
+
const tokenFile = path.join(dir, "token");
|
|
247
|
+
fs.writeFileSync(tokenFile, SECRET + "\n", { mode: 0o600 });
|
|
248
|
+
const s = ensureAskpassScript(dir);
|
|
249
|
+
const env = { PATH: process.env.PATH, LA_GIT_TOKEN_FILE: tokenFile };
|
|
250
|
+
assert.equal(execFileSync(s, ["Username for 'https://github.com': "], { env, encoding: "utf8" }).trim(), "x-access-token");
|
|
251
|
+
assert.equal(execFileSync(s, ["Password for 'https://x-access-token@github.com': "], { env, encoding: "utf8" }).trim(), SECRET);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------- job manager
|
|
255
|
+
|
|
256
|
+
const uuid = () => crypto.randomUUID();
|
|
257
|
+
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
|
258
|
+
const until = async (cond, ms = 3000) => { const t0 = Date.now(); while (!cond()) { if (Date.now() - t0 > ms) throw new Error("until: timed out"); await tick(5); } };
|
|
259
|
+
const params = (over = {}) => ({ jobId: uuid(), kind: "fix", repoRef: REF, policy: { operationMode: "assist", automationLevel: 2, ceiling: "assist" }, budget: { tokens: 50_000, seconds: 60 },
|
|
260
|
+
llm: { alias: "a", keyRef: "ab".repeat(32) }, inputs: [], ...over });
|
|
261
|
+
function mgr({ materializer, resolveWorkspace = () => null }) {
|
|
262
|
+
const frames = [];
|
|
263
|
+
const session = { enqueue: async (type, payload) => { frames.push({ type, payload }); return { acked: Promise.resolve() }; } };
|
|
264
|
+
const started = [];
|
|
265
|
+
const runner = { name: "r", needsLlm: false, start(job) { started.push(job.jobId); return Promise.resolve({ outcome: "no_change" }); }, cancel() {} };
|
|
266
|
+
const config = resolveJobsConfig({ LOCAL_AGENT_JOBS: "true", LOCAL_AGENT_JOBS_EVENT_MIN_MS: "50", LOCAL_AGENT_JOBS_TERMINAL_RETRY_MS: "10" }, noop);
|
|
267
|
+
const m = createJobManager({ session, tenantId: TENANT, resolveWorkspace, materializer, config, env: { LOCAL_AGENT_DLP_MODE: "enforce" }, runner, log: { warn: noop } });
|
|
268
|
+
return { m, frames, started };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("manager: without a materializer (flag off) a workspace not served here is rejected unauthorized, as before", () => {
|
|
272
|
+
const { m, started } = mgr({ materializer: null });
|
|
273
|
+
const r = m.handleAssign({ params: params(), workspaceId: WS });
|
|
274
|
+
assert.deepEqual(r, { reject: "unauthorized" });
|
|
275
|
+
assert.equal(started.length, 0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("manager: a repo outside the allowlist is rejected unauthorized and nothing is cloned", () => {
|
|
279
|
+
const { mat } = setup({ allow: "github.com/other/thing" });
|
|
280
|
+
let ensured = 0;
|
|
281
|
+
const spy = { admit: (...a) => mat.admit(...a), ensure: () => { ensured++; }, bindAudit() {} };
|
|
282
|
+
const { m, started } = mgr({ materializer: spy });
|
|
283
|
+
assert.deepEqual(m.handleAssign({ params: params(), workspaceId: WS }), { reject: "unauthorized" });
|
|
284
|
+
assert.equal(ensured, 0);
|
|
285
|
+
assert.equal(started.length, 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("manager: an allowlisted workspace is accepted, materialized INSIDE the job (after the ack), then the runner starts", async () => {
|
|
289
|
+
const order = [];
|
|
290
|
+
let release;
|
|
291
|
+
const gate = new Promise((r) => { release = r; });
|
|
292
|
+
const materializer = { admit: () => true, bindAudit() {}, ensure: async (id, ref) => { order.push(["ensure", id, ref.repo]); await gate; order.push(["ensured"]); return { root: "/x" }; } };
|
|
293
|
+
const { m, started, frames } = mgr({ materializer });
|
|
294
|
+
const p = params();
|
|
295
|
+
assert.deepEqual(m.handleAssign({ params: p, workspaceId: WS }), { accept: true });
|
|
296
|
+
await until(() => order.length === 1);
|
|
297
|
+
assert.equal(started.length, 0, "the runner waits for the clone");
|
|
298
|
+
release();
|
|
299
|
+
await until(() => started.length === 1);
|
|
300
|
+
assert.deepEqual(order.map((o) => o[0]), ["ensure", "ensured"]);
|
|
301
|
+
await until(() => frames.some((f) => f.type === "job.result"));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("manager: a failed materialization (no credential, auth) ends the job failed/infra with a code only; the runner never starts", async () => {
|
|
305
|
+
const materializer = { admit: () => true, bindAudit() {}, ensure: async () => { throw new MaterializeError("auth"); } };
|
|
306
|
+
const { m, started, frames } = mgr({ materializer });
|
|
307
|
+
const p = params();
|
|
308
|
+
assert.deepEqual(m.handleAssign({ params: p, workspaceId: WS }), { accept: true });
|
|
309
|
+
await until(() => frames.some((f) => f.type === "job.result"));
|
|
310
|
+
const res = frames.find((f) => f.type === "job.result").payload;
|
|
311
|
+
assert.equal(res.outcome, "failed");
|
|
312
|
+
const fin = frames.filter((f) => f.type === "job.event").pop().payload;
|
|
313
|
+
assert.equal(fin.state, "failed");
|
|
314
|
+
assert.equal(fin.errorCode, "infra");
|
|
315
|
+
assert.equal(started.length, 0);
|
|
316
|
+
assert.ok(!JSON.stringify(frames).includes("auth"), "the wire carries infra, not the cause");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("manager: job.cancel while the workspace is being cloned aborts the clone and the runner never starts", async () => {
|
|
320
|
+
let sig = null;
|
|
321
|
+
const materializer = { admit: () => true, bindAudit() {}, ensure: (id, ref, signal) => new Promise((_, rej) => { sig = signal; signal.addEventListener("abort", () => rej(new MaterializeError("aborted"))); }) };
|
|
322
|
+
const { m, started, frames } = mgr({ materializer });
|
|
323
|
+
const p = params();
|
|
324
|
+
m.handleAssign({ params: p, workspaceId: WS });
|
|
325
|
+
await until(() => sig);
|
|
326
|
+
m.handleCancel({ params: { jobId: p.jobId, reason: "user" } });
|
|
327
|
+
await until(() => frames.some((f) => f.type === "job.result"));
|
|
328
|
+
assert.equal(frames.find((f) => f.type === "job.result").payload.outcome, "cancelled");
|
|
329
|
+
assert.equal(started.length, 0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("manager + real materializer: a workspace of another tenant already open here is not touched (resolver refuses, materializer refuses)", async () => {
|
|
333
|
+
const { mat, wsManager, cache } = setup();
|
|
334
|
+
const d = path.join(cache, "x"); fs.mkdirSync(d);
|
|
335
|
+
await wsManager.open(WS, d, { tenantId: OTHER });
|
|
336
|
+
const resolve = (id) => { const w = wsManager.list().find((x) => x.id === id); return w && String(w.tenantId).toLowerCase() === TENANT ? { root: w.root } : null; };
|
|
337
|
+
const { m, started } = mgr({ materializer: mat, resolveWorkspace: resolve });
|
|
338
|
+
assert.deepEqual(m.handleAssign({ params: params(), workspaceId: WS }), { reject: "unauthorized" });
|
|
339
|
+
assert.equal(started.length, 0);
|
|
340
|
+
assert.equal(wsManager.list().find((x) => x.id === WS).tenantId, OTHER);
|
|
341
|
+
});
|