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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -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
- 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 });
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
- 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;
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
  }
@@ -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
+ });