@proagentstore/cli 0.4.14 → 0.4.15

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.
@@ -0,0 +1,143 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { relative, resolve, sep } from "node:path";
4
+ /**
5
+ * Read-only code inspection for the coding runtime — the "eyes" the Co-pilot/Chat use
6
+ * to GROUND their answers in the real repo (read a file, `git diff`, list the tree)
7
+ * WITHOUT driving the live CLI. All access is confined to the session's workDir.
8
+ *
9
+ * Two pure, separately-tested primitives carry the safety:
10
+ * - resolveInside(): rejects any path escaping the repo root (../, absolute, sibling
11
+ * prefix, symlink escape).
12
+ * - gitArgv(): maps a fixed command enum to a fixed argv — no user string ever
13
+ * becomes a git token except a resolveInside-validated path after a literal `--`.
14
+ */
15
+ /** Resolve `rel` under `root`, refusing anything that escapes it. Pure (no fs) EXCEPT the
16
+ * optional symlink check, which is what defends against a symlink inside the repo pointing
17
+ * at e.g. ~/.ssh. Throws on any escape. */
18
+ export function resolveInside(root, rel, opts = {}) {
19
+ const rootAbs = resolve(root);
20
+ const abs = resolve(rootAbs, rel);
21
+ // `resolve` collapses `..`, so a traversal or absolute escape lands outside rootAbs.
22
+ // The explicit `+ sep` blocks a sibling-prefix attack (/repo vs /repo-secrets).
23
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) {
24
+ throw new InspectError(`path escapes the repo: ${rel}`);
25
+ }
26
+ if (opts.checkSymlink && existsSync(abs)) {
27
+ // A symlink inside the repo could still point outside it — resolve the real path and
28
+ // re-check the same invariant.
29
+ const real = realpathSync(abs);
30
+ const realRoot = realpathSync(rootAbs);
31
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
32
+ throw new InspectError(`path resolves (via symlink) outside the repo: ${rel}`);
33
+ }
34
+ }
35
+ return abs;
36
+ }
37
+ /** Map a whitelisted command enum to a fixed git argv. `path` (already validated by the
38
+ * caller via resolveInside) is only ever appended after a literal `--` separator. */
39
+ export function gitArgv(cmd, opts = {}) {
40
+ const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
41
+ switch (cmd) {
42
+ case "status":
43
+ return ["status", "--short"];
44
+ case "diff":
45
+ return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
46
+ case "diff-stat":
47
+ return ["diff", "--stat"];
48
+ case "log":
49
+ return ["log", "--oneline", "-n", String(clampN)];
50
+ case "ls-files":
51
+ return ["ls-files"];
52
+ default:
53
+ throw new InspectError(`unsupported git command: ${cmd}`);
54
+ }
55
+ }
56
+ export class InspectError extends Error {
57
+ constructor(msg) {
58
+ super(msg);
59
+ this.name = "InspectError";
60
+ }
61
+ }
62
+ const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
63
+ const HARD_MAX_FILE_BYTES = 128 * 1024;
64
+ /** Read a text file inside the repo. Rejects traversal, oversize, and binary files. */
65
+ export function readRepoFile(workDir, relPath, maxBytes) {
66
+ const abs = resolveInside(workDir, relPath, { checkSymlink: true });
67
+ const st = statSync(abs);
68
+ if (!st.isFile())
69
+ throw new InspectError(`not a regular file: ${relPath}`);
70
+ const cap = Math.min(maxBytes ?? DEFAULT_MAX_FILE_BYTES, HARD_MAX_FILE_BYTES);
71
+ const buf = readFileSync(abs);
72
+ // Binary sniff: a NUL byte in the first 8KB → don't feed bytes to the model.
73
+ const head = buf.subarray(0, 8192);
74
+ if (head.includes(0))
75
+ return { path: relPath, size: st.size, truncated: false, binary: true };
76
+ const truncated = buf.length > cap;
77
+ return { path: relPath, size: st.size, truncated, content: buf.subarray(0, cap).toString("utf-8") };
78
+ }
79
+ /** Run a whitelisted read-only git command in the repo. Never uses a shell. */
80
+ export function runRepoGit(workDir, cmd, opts = {}) {
81
+ if (!existsSync(resolve(workDir, ".git")))
82
+ throw new InspectError("not a git repo");
83
+ const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
84
+ const argv = gitArgv(cmd, { relPath, n: opts.n });
85
+ let out = "";
86
+ try {
87
+ out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
88
+ }
89
+ catch (e) {
90
+ // git exits non-zero for benign cases (e.g. `diff` on nothing) — surface stdout if present.
91
+ const err = e;
92
+ out = err.stdout ?? "";
93
+ if (!out)
94
+ throw new InspectError(err.message || `git ${cmd} failed`);
95
+ }
96
+ const cap = opts.maxBytes ?? 64 * 1024;
97
+ const truncated = out.length > cap;
98
+ return { cmd, output: truncated ? out.slice(0, cap) : out, truncated };
99
+ }
100
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".turbo", "coverage", ".wrangler"]);
101
+ /** Bounded recursive file tree (names/type/size only — no contents). */
102
+ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
103
+ const start = resolveInside(workDir, relPath, { checkSymlink: true });
104
+ const depthCap = Math.max(1, Math.min(4, maxDepth));
105
+ const entryCap = Math.max(1, Math.min(1000, maxEntries));
106
+ const entries = [];
107
+ const queue = [{ dir: start, depth: 0 }];
108
+ let truncated = false;
109
+ while (queue.length) {
110
+ const { dir, depth } = queue.shift();
111
+ let items;
112
+ try {
113
+ items = readdirSync(dir, { withFileTypes: true });
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ for (const it of items) {
119
+ if (it.name.startsWith(".") || IGNORE_DIRS.has(it.name))
120
+ continue;
121
+ if (entries.length >= entryCap) {
122
+ truncated = true;
123
+ return { root: relPath, entries, truncated };
124
+ }
125
+ const abs = resolve(dir, it.name);
126
+ const rel = relative(workDir, abs);
127
+ if (it.isDirectory()) {
128
+ entries.push({ path: rel, type: "dir" });
129
+ if (depth + 1 < depthCap)
130
+ queue.push({ dir: abs, depth: depth + 1 });
131
+ }
132
+ else if (it.isFile()) {
133
+ let size;
134
+ try {
135
+ size = statSync(abs).size;
136
+ }
137
+ catch { }
138
+ entries.push({ path: rel, type: "file", size });
139
+ }
140
+ }
141
+ }
142
+ return { root: relPath, entries, truncated };
143
+ }
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
3
  import { defaultStatePath, HeadlessSession } from "./headless.js";
4
+ import { InspectError, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
4
5
  import { ensureRepo, sanitizeSessionName } from "./tmux.js";
5
6
  /** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
6
7
  const MAX_PANE = 64 * 1024;
@@ -18,9 +19,39 @@ export class CodingRuntime {
18
19
  constructor(reposBaseDir = join(homedir(), ".config", "proagentstore", "repos")) {
19
20
  this.reposBaseDir = reposBaseDir;
20
21
  }
21
- /** Capabilities advertised to PAGS at registration. */
22
+ /** Capabilities advertised to PAGS at registration. `coding.inspect` signals the
23
+ * read-only code-inspection endpoints exist, so the cloud offers the grounding tools
24
+ * (older runners omit it → the cloud degrades to terminal-only). */
22
25
  static capabilities() {
23
- return ["coding.sessions", "coding.stream", "human.takeover"];
26
+ return ["coding.sessions", "coding.stream", "human.takeover", "coding.inspect"];
27
+ }
28
+ /**
29
+ * Resolve the workDir for a read-only inspection. Prefer the tracked session's real
30
+ * workDir (authoritative even for managed clone dirs); fall back to an explicit path
31
+ * (the cloud passes the D1 `repo.workdir` when the session map is empty after a runner
32
+ * restart). Expands a leading `~` the same way start() does.
33
+ */
34
+ resolveWorkDir(input) {
35
+ if (input.sessionId) {
36
+ const s = this.sessions.get(input.sessionId);
37
+ if (s)
38
+ return s.config.workDir;
39
+ }
40
+ if (input.workDir)
41
+ return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
42
+ throw new InspectError("no session or workDir to inspect");
43
+ }
44
+ /** Read one file inside the session's repo (traversal-guarded, size-capped). */
45
+ readFile(input) {
46
+ return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes);
47
+ }
48
+ /** Run a whitelisted read-only git command in the session's repo. */
49
+ git(input) {
50
+ return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
51
+ }
52
+ /** Bounded recursive file tree of the session's repo (names/type/size only). */
53
+ tree(input) {
54
+ return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
24
55
  }
25
56
  static taskTypes() {
26
57
  return ["coding.session"];
@@ -222,6 +222,35 @@ async function route(runner, req, res) {
222
222
  }
223
223
  return json(res, 200, { killed, sessions: targets });
224
224
  }
225
+ // ── Read-only code inspection (the Co-pilot/Chat's "eyes" — no CLI driving) ──
226
+ // Confined to the session's workDir by inspect.ts; errors surface as 400.
227
+ if (req.method === "POST" && path === "/coding/read-file") {
228
+ const b = await readJson(req);
229
+ try {
230
+ return json(res, 200, runner.coding.readFile(b));
231
+ }
232
+ catch (e) {
233
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
234
+ }
235
+ }
236
+ if (req.method === "POST" && path === "/coding/git") {
237
+ const b = await readJson(req);
238
+ try {
239
+ return json(res, 200, runner.coding.git(b));
240
+ }
241
+ catch (e) {
242
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
243
+ }
244
+ }
245
+ if (req.method === "POST" && path === "/coding/tree") {
246
+ const b = await readJson(req);
247
+ try {
248
+ return json(res, 200, runner.coding.tree(b));
249
+ }
250
+ catch (e) {
251
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
252
+ }
253
+ }
225
254
  if (req.method === "POST" && path === "/coding/event") {
226
255
  // Brain progress events — recorded by PAGS, ignored locally. Accept + ack.
227
256
  return json(res, 200, { ok: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",