@proagentstore/cli 0.4.57 → 0.4.59

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.
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { InspectError } from "./inspect.js";
4
+ import { InspectError, networkGitEnv } from "./inspect.js";
5
5
  /**
6
6
  * May this string become a git token?
7
7
  *
@@ -32,11 +32,18 @@ export function gitWriteArgv(cmd, opts = {}) {
32
32
  // discard work.
33
33
  return ["checkout", opts.branch.trim(), "--"];
34
34
  }
35
+ case "fast-forward":
36
+ // No caller-supplied token at all: the branch is whatever HEAD is (checked against the
37
+ // declared one BEFORE this runs), the remote is the upstream git records for it. `--ff-only`
38
+ // is the whole contract — git aborts, touching nothing, when the histories have diverged.
39
+ // `--no-rebase` so a `pull.rebase=true` config on the owner's machine cannot turn the
40
+ // abort into a rebase of their local commits.
41
+ return ["pull", "--ff-only", "--no-rebase"];
35
42
  default:
36
43
  throw new InspectError(`unsupported git write command: ${cmd}`);
37
44
  }
38
45
  }
39
- function git(workDir, argv) {
46
+ function git(workDir, argv, opts = {}) {
40
47
  // stderr is PIPED rather than inherited: git narrates a checkout on stderr, and the runner's
41
48
  // console is a user-facing log, not a place for `Switched to branch 'main'`. Piping it is also
42
49
  // what makes `e.stderr` available, which is the only honest sentence to put on the card when git
@@ -44,11 +51,22 @@ function git(workDir, argv) {
44
51
  return execFileSync("git", argv, {
45
52
  cwd: workDir,
46
53
  encoding: "utf-8",
47
- timeout: 15_000,
54
+ timeout: opts.timeout ?? 15_000,
48
55
  maxBuffer: 1024 * 1024,
49
56
  stdio: ["ignore", "pipe", "pipe"],
57
+ env: opts.env,
50
58
  }).toString();
51
59
  }
60
+ /** git's diagnosis is the FIRST `fatal:`/`error:` line of stderr; the tail is boilerplate. */
61
+ function gitFailureLine(e) {
62
+ const err = e;
63
+ const lines = String(err.stderr || err.message || "")
64
+ .split("\n")
65
+ .map((l) => l.trim())
66
+ .filter(Boolean);
67
+ const line = lines.find((l) => /^(fatal|error):/i.test(l)) ?? lines[0] ?? "git failed";
68
+ return line.replace(/^(fatal|error):\s*/i, "").slice(0, 200);
69
+ }
52
70
  /** The current branch, or the short SHA when detached, or null when even that fails. */
53
71
  function currentBranch(workDir) {
54
72
  try {
@@ -134,3 +152,109 @@ export function switchRepoBranch(workDir, branch) {
134
152
  }
135
153
  return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
136
154
  }
155
+ /**
156
+ * Bring a CLEAN checkout up to its upstream, or refuse and change nothing (#802).
157
+ *
158
+ * Every precondition is checked HERE, at the hands, not only in the cloud — the same rule
159
+ * `switchRepoBranch` follows and for the same reason: the cloud's picture of the tree is a read
160
+ * taken moments earlier over a relay, and the write has to be safe against the tree as it IS.
161
+ *
162
+ * dirty refused. A fast-forward that touches a file with uncommitted edits is aborted by
163
+ * git anyway, but one that does not touch it silently succeeds and leaves the diff
164
+ * sitting on a base it was not written against. Refusing is the only answer that
165
+ * cannot change what somebody's uncommitted work means.
166
+ * off-branch refused when `branch` is given and HEAD is elsewhere. The declared branch is the
167
+ * one the cloud judged stale; pulling whatever the checkout happens to be on would
168
+ * be acting on a verdict about a different ref.
169
+ * detached refused — there is no branch for a pull to advance.
170
+ * no-upstream refused — `git pull` with no tracking information is a prompt, and a guess about
171
+ * which remote was meant is exactly the decision this module must never make.
172
+ * diverged NOT a precondition: `--ff-only` is the check, and git makes it atomically against
173
+ * the real history. It surfaces as `error`, with the tree untouched.
174
+ *
175
+ * Never creates a branch, never sets an upstream, never stashes.
176
+ */
177
+ export function fastForwardRepo(workDir, opts = {}) {
178
+ const want = typeof opts.branch === "string" && opts.branch.trim() ? opts.branch.trim() : null;
179
+ if (want !== null && !isSwitchableBranchName(want))
180
+ throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
181
+ const base = { ok: false, changed: false, branch: null, upstream: null, from: null, to: null, commits: null, dirty: false };
182
+ if (!existsSync(resolve(workDir, ".git")))
183
+ return { ...base, refused: "not-a-repo" };
184
+ let abbrev;
185
+ try {
186
+ abbrev = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
187
+ }
188
+ catch {
189
+ return { ...base, refused: "unknown-head" };
190
+ }
191
+ if (!abbrev)
192
+ return { ...base, refused: "unknown-head" };
193
+ if (abbrev === "HEAD")
194
+ return { ...base, refused: "detached" };
195
+ const branch = abbrev;
196
+ let dirty;
197
+ try {
198
+ dirty = isDirty(workDir);
199
+ }
200
+ catch (e) {
201
+ return { ...base, branch, error: gitFailureLine(e) };
202
+ }
203
+ if (dirty)
204
+ return { ...base, branch, dirty: true, refused: "dirty" };
205
+ if (want !== null && branch !== want)
206
+ return { ...base, branch, refused: "off-branch" };
207
+ let upstream;
208
+ try {
209
+ upstream = git(workDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
210
+ }
211
+ catch {
212
+ return { ...base, branch, refused: "no-upstream" };
213
+ }
214
+ if (!upstream)
215
+ return { ...base, branch, refused: "no-upstream" };
216
+ let from;
217
+ try {
218
+ from = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
219
+ }
220
+ catch {
221
+ from = null;
222
+ }
223
+ try {
224
+ // The one network call in this file: the no-prompt environment `inspect.ts` uses for
225
+ // fetches, and a timeout above the runner's fetch cap, because a pull IS a fetch first.
226
+ git(workDir, gitWriteArgv("fast-forward"), { timeout: 30_000, env: networkGitEnv() });
227
+ }
228
+ catch (e) {
229
+ // `--ff-only` refused, the network failed, or auth was needed and (correctly) not prompted
230
+ // for. In every case git left the tree as it was; the sentence says which.
231
+ return { ...base, branch, upstream, from, to: from, error: gitFailureLine(e) };
232
+ }
233
+ // CONFIRM, do not assume — read HEAD back and count what arrived from git, not from the
234
+ // exit code.
235
+ let to;
236
+ try {
237
+ to = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
238
+ }
239
+ catch {
240
+ to = null;
241
+ }
242
+ let commits = null;
243
+ if (from && to) {
244
+ try {
245
+ const n = Number.parseInt(git(workDir, ["rev-list", "--count", `${from}..${to}`]).trim(), 10);
246
+ commits = Number.isFinite(n) ? n : null;
247
+ }
248
+ catch {
249
+ commits = null;
250
+ }
251
+ }
252
+ let dirtyAfter;
253
+ try {
254
+ dirtyAfter = isDirty(workDir);
255
+ }
256
+ catch {
257
+ dirtyAfter = null;
258
+ }
259
+ return { ok: to !== null, changed: Boolean(from && to && from !== to), branch, upstream, from, to, commits, dirty: dirtyAfter };
260
+ }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  /**
@@ -72,6 +72,49 @@ export function authenticatedCloneUrl(cloneUrl, token, username) {
72
72
  const user = encodeURIComponent(username || "x-access-token");
73
73
  return cloneUrl.replace(/^https:\/\//i, `https://${user}:${encodeURIComponent(token)}@`);
74
74
  }
75
+ /**
76
+ * Parse a GitHub SSH welcome banner into an identity name.
77
+ *
78
+ * GitHub writes to stderr on a successful `ssh -T git@github.com`:
79
+ * `Hi <name>! You've successfully authenticated, but GitHub does not provide shell access.`
80
+ *
81
+ * For a deploy key the name is `<org>/<repo>`. For a user account it is the login.
82
+ * This function is PURE so it can be unit-tested without a network.
83
+ */
84
+ export function parseSshIdentity(raw) {
85
+ // The banner arrives on stderr; strip ANSI color/style sequences (`ESC[...m`) that some SSH
86
+ // versions prepend. The ESC byte is expressed via String.fromCharCode rather than as a literal
87
+ // in a regex pattern, because Biome's noControlCharactersInRegex rule rejects control characters
88
+ // in regex literals (same rule that transcript-lines.ts and tmux.ts suppress with biome-ignore).
89
+ const ESC = String.fromCharCode(27);
90
+ const clean = raw.split(ESC).join("").replace(/\[[0-9;]*m/g, "").trim();
91
+ const m = clean.match(/^Hi\s+([^!]+)!/m);
92
+ return m ? m[1].trim() : null;
93
+ }
94
+ /**
95
+ * Ask the machine what git identity SSH presents for `host`.
96
+ *
97
+ * Uses `BatchMode=yes` so it never prompts for a passphrase, and `ConnectTimeout=5` so a
98
+ * firewall that drops packets (rather than refusing) does not stall the diagnostics response.
99
+ * `StrictHostKeyChecking=accept-new` avoids an interactive prompt on first connection.
100
+ *
101
+ * Never throws — every failure is an identity of `null`, because this is a transparency probe
102
+ * and a network hiccup must not make the diagnostics endpoint useless.
103
+ */
104
+ export function probeGitSshIdentity(host) {
105
+ // `ssh -T` exits non-zero (1) even on success — GitHub's welcome message deliberately closes
106
+ // the connection without a shell. `spawnSync` is used rather than `execFileSync` so a non-zero
107
+ // exit does not throw; the content of stderr is what matters, not the exit code.
108
+ const result = spawnSync("ssh", ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=5", `git@${host}`], {
109
+ encoding: "utf-8",
110
+ timeout: 10_000,
111
+ });
112
+ // stderr carries the welcome message; stdout is empty for a normal `ssh -T`.
113
+ const raw = String(result.stderr ?? "").slice(0, 500);
114
+ const identity = parseSshIdentity(raw);
115
+ const isDeployKey = identity === null ? null : identity.includes("/");
116
+ return { checked: true, host, identity, isDeployKey, raw };
117
+ }
75
118
  /**
76
119
  * Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
77
120
  * — an existing checkout is left alone (no clobber). For private repos the cloud
@@ -2,8 +2,8 @@ import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
3
  import { RunnerInputError } from "../errors.js";
4
4
  import { defaultStatePath, HeadlessSession } from "./headless.js";
5
- import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
6
- import { switchRepoBranch } from "./repo-write.js";
5
+ import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
6
+ import { fastForwardRepo, switchRepoBranch } from "./repo-write.js";
7
7
  import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
8
8
  import { asTurnAuthor } from "./turn-author.js";
9
9
  /** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
@@ -54,17 +54,33 @@ export class CodingRuntime {
54
54
  }
55
55
  /** Run a whitelisted read-only git command in the session's repo. */
56
56
  git(input) {
57
- return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
57
+ return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n, ref: input.ref });
58
58
  }
59
59
  /**
60
- * The ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
61
- * it declared, or refuse. See `repo-write.ts`: fixed argv, clean tree required, nothing in the
62
- * `checkout .`/`reset`/`clean`/`stash` family exists to be reached.
60
+ * Where the checkout stands against its upstream — fetch (cached), then count ahead/behind
61
+ * (#785). Never pulls. The one endpoint here that reaches the NETWORK, which is why it carries
62
+ * its own cache and its own no-prompt environment (`inspect.ts`).
63
+ */
64
+ sync(input) {
65
+ return repoSync(this.resolveWorkDir(input), { branch: input.branch, forceFetch: input.forceFetch });
66
+ }
67
+ /**
68
+ * The writes the platform may make in a checkout by itself — put it back on a branch it
69
+ * declared (#322), or fast-forward it to its upstream (#802) — or refuse. See `repo-write.ts`:
70
+ * fixed argv, clean tree required, nothing in the `checkout .`/`reset`/`clean`/`stash` family
71
+ * exists to be reached.
63
72
  */
64
73
  gitWrite(input) {
65
- if (input.cmd !== "switch-branch")
66
- throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
67
- return switchRepoBranch(this.resolveWorkDir(input), input.branch);
74
+ const workDir = this.resolveWorkDir(input);
75
+ if (input.cmd === "switch-branch") {
76
+ if (typeof input.branch !== "string")
77
+ throw new InspectError("switch-branch needs a branch");
78
+ return switchRepoBranch(workDir, input.branch);
79
+ }
80
+ // The second verb (#802): bring a clean checkout that is ON `branch` up to its upstream.
81
+ if (input.cmd === "fast-forward")
82
+ return fastForwardRepo(workDir, { branch: input.branch });
83
+ throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
68
84
  }
69
85
  /**
70
86
  * Find something in the repo — by file CONTENT or by file NAME (#508).
@@ -158,6 +174,8 @@ export class CodingRuntime {
158
174
  // exactly the question asked about a session that just stopped.
159
175
  authResolved: session.authResolved,
160
176
  engineRuntime: session.engineRuntime,
177
+ engineMode: session.engineMode,
178
+ engineModeWarning: session.engineModeWarning,
161
179
  // Reported on EVERY capture, including one where the session is not alive: "how did the
162
180
  // last turn end" is exactly the question asked about a session that just stopped, and
163
181
  // the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
@@ -252,6 +270,8 @@ export class CodingRuntime {
252
270
  takeover: this.takeovers.has(sessionId),
253
271
  authResolved: s.authResolved,
254
272
  engineRuntime: s.engineRuntime,
273
+ engineMode: s.engineMode,
274
+ engineModeWarning: s.engineModeWarning,
255
275
  ghGuard: s.ghGuard,
256
276
  }));
257
277
  }
@@ -1,6 +1,8 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { URL } from "node:url";
3
3
  import { LocalRunner, RunnerInputError } from "./runner.js";
4
+ import { probeGitSshIdentity } from "./coding/repo.js";
5
+ import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
4
6
  export function createRunnerServer(runner) {
5
7
  return createServer(async (req, res) => {
6
8
  try {
@@ -176,6 +178,95 @@ async function route(runner, req, res) {
176
178
  // use tmux, have their own /tmux/* endpoints and are unaffected.
177
179
  return json(res, 200, { tracked: runner.coding.diagnostics() });
178
180
  }
181
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/git-identity") {
182
+ // Report the SSH identity this machine presents to github.com (#684). Called by
183
+ // `coding_diagnostics` to surface deploy-key vs user-account mismatches without a
184
+ // failed clone. Never throws on the runner side — a network hiccup returns identity:null.
185
+ const b = req.method === "POST" ? await readJson(req).catch(() => ({})) : {};
186
+ const host = String(b.host || "github.com");
187
+ return json(res, 200, probeGitSshIdentity(host));
188
+ }
189
+ // ── GitHub credential scope (#688) ────────────────────────────────────────
190
+ // Read-only: reports the authenticated gh login + org memberships. Surfaces
191
+ // which account and organisations the runner's credentials can reach, before
192
+ // any browse operation begins. Never writes, never mutates anything on GitHub.
193
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-credentials") {
194
+ return json(res, 200, getGithubCredentialScope());
195
+ }
196
+ // ── GitHub org + repo enumeration (#685) ──────────────────────────────────
197
+ // Read-only: lists orgs and repos reachable by the machine's `gh` credentials.
198
+ // Never writes, never mutates anything on GitHub.
199
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-orgs") {
200
+ return json(res, 200, listGithubOrgs());
201
+ }
202
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-repos") {
203
+ const b = req.method === "POST"
204
+ ? await readJson(req).catch(() => ({}))
205
+ : {};
206
+ // Query params override body for GET callers (more REST-idiomatic).
207
+ const qOwner = url.searchParams.get("owner");
208
+ const qLimit = url.searchParams.get("limit");
209
+ const qSince = url.searchParams.get("since");
210
+ const qVis = url.searchParams.get("visibility");
211
+ const input = {
212
+ owner: qOwner ?? b.owner,
213
+ limit: qLimit ? Number(qLimit) : b.limit,
214
+ since: qSince ?? b.since,
215
+ visibility: (qVis === "public" || qVis === "private" || qVis === "all")
216
+ ? qVis
217
+ : b.visibility,
218
+ };
219
+ return json(res, 200, listGithubRepos(input));
220
+ }
221
+ // ── GitHub repository search (#686) ───────────────────────────────────────
222
+ // Read-only: searches repos reachable by the machine's `gh` credentials via
223
+ // GitHub's own search API. One API call per query (no per-repo fan-out).
224
+ // Results are cached in-process for 5 minutes to guard the 30 req/min quota.
225
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-search") {
226
+ const b = req.method === "POST"
227
+ ? await readJson(req).catch(() => ({}))
228
+ : {};
229
+ const qQuery = url.searchParams.get("query");
230
+ const qOwner = url.searchParams.get("owner");
231
+ const qLang = url.searchParams.get("language");
232
+ const qTopic = url.searchParams.get("topic");
233
+ const qPushedAfter = url.searchParams.get("pushedAfter");
234
+ const qOpenPrs = url.searchParams.get("openPrs");
235
+ const qLimit = url.searchParams.get("limit");
236
+ const qSort = url.searchParams.get("sort");
237
+ const bTyped = b;
238
+ const input = {
239
+ query: qQuery ?? bTyped.query,
240
+ owner: qOwner ?? bTyped.owner,
241
+ language: qLang ?? bTyped.language,
242
+ topic: qTopic ?? bTyped.topic,
243
+ pushedAfter: qPushedAfter ?? bTyped.pushedAfter,
244
+ openPrs: qOpenPrs !== null ? qOpenPrs === "true" : bTyped.openPrs,
245
+ limit: qLimit ? Number(qLimit) : bTyped.limit,
246
+ sort: (qSort === "stars" || qSort === "forks" || qSort === "updated")
247
+ ? qSort
248
+ : bTyped.sort,
249
+ };
250
+ return json(res, 200, searchGithubRepos(input));
251
+ }
252
+ // ── GitHub repository detail (#687) ──────────────────────────────────────
253
+ // Read-only: fetches issues, PRs, and branches for a given owner/repo slug
254
+ // via `gh api`. Results are cached in-process for 2 minutes.
255
+ if ((req.method === "GET" || req.method === "POST") && path === "/coding/github-repo-detail") {
256
+ const b = req.method === "POST"
257
+ ? await readJson(req).catch(() => ({}))
258
+ : {};
259
+ const bTyped = b;
260
+ const qRepo = url.searchParams.get("repo");
261
+ const qLimit = url.searchParams.get("limit");
262
+ const qState = url.searchParams.get("state");
263
+ const input = {
264
+ repo: qRepo ?? bTyped.repo ?? "",
265
+ limit: qLimit ? Number(qLimit) : bTyped.limit,
266
+ state: (qState === "all" || qState === "open") ? qState : bTyped.state,
267
+ };
268
+ return json(res, 200, getGithubRepoDetail(input));
269
+ }
179
270
  if (req.method === "POST" && path === "/coding/browse") {
180
271
  const { readdirSync, statSync } = await import("node:fs");
181
272
  const { resolve } = await import("node:path");
@@ -240,9 +331,23 @@ async function route(runner, req, res) {
240
331
  return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
241
332
  }
242
333
  }
243
- // The ONE write surface (#322). A standing policy may put a checkout back on the branch it
244
- // declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
245
- // cloud reports as "asked, not confirmed" rather than as done.
334
+ // Where the checkout stands against its upstream (#785): fetch (cached a minute), then count
335
+ // ahead/behind. Never pulls. A SEPARATE endpoint for the reason `/coding/search` is one: an
336
+ // older runner 404s it, which the cloud reads as "unverified" — never as "in sync".
337
+ if (req.method === "POST" && path === "/coding/sync") {
338
+ const b = await readJson(req);
339
+ try {
340
+ return json(res, 200, runner.coding.sync(b));
341
+ }
342
+ catch (e) {
343
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
344
+ }
345
+ }
346
+ // The ONE write surface (#322, #802). A standing policy may put a checkout back on the branch
347
+ // it declared, and a run may fast-forward a clean checkout to its upstream before it starts; it
348
+ // may not commit, discard, or push. An older runner 404s the endpoint (and a 0.4.58 runner
349
+ // 400s the `fast-forward` verb), which the cloud reports as "asked, not confirmed" rather than
350
+ // as done.
246
351
  if (req.method === "POST" && path === "/coding/git-write") {
247
352
  const b = await readJson(req);
248
353
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",