@proagentstore/cli 0.4.56 → 0.4.58
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/dist/browser-runner/coding/engine-acts.js +7 -7
- package/dist/browser-runner/coding/engine-adapter.js +186 -0
- package/dist/browser-runner/coding/engine-usage.js +27 -3
- package/dist/browser-runner/coding/github-browse.js +796 -0
- package/dist/browser-runner/coding/handlers.js +1 -1
- package/dist/browser-runner/coding/headless.js +121 -118
- package/dist/browser-runner/coding/inspect.js +188 -13
- package/dist/browser-runner/coding/repo.js +44 -1
- package/dist/browser-runner/coding/runtime.js +20 -3
- package/dist/browser-runner/server.js +103 -0
- package/package.json +1 -1
|
@@ -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,7 +2,7 @@ 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";
|
|
5
|
+
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
|
|
6
6
|
import { switchRepoBranch } from "./repo-write.js";
|
|
7
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
8
|
import { asTurnAuthor } from "./turn-author.js";
|
|
@@ -54,7 +54,15 @@ 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
|
+
}
|
|
59
|
+
/**
|
|
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 });
|
|
58
66
|
}
|
|
59
67
|
/**
|
|
60
68
|
* The ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
|
|
@@ -117,6 +125,7 @@ export class CodingRuntime {
|
|
|
117
125
|
env: input.env,
|
|
118
126
|
statePath: defaultStatePath(this.reposBaseDir),
|
|
119
127
|
resumeFrom: input.resumeFrom,
|
|
128
|
+
seed: input.seed,
|
|
120
129
|
ghScope: input.ghScope,
|
|
121
130
|
bin: input.bin,
|
|
122
131
|
});
|
|
@@ -126,8 +135,12 @@ export class CodingRuntime {
|
|
|
126
135
|
// clears its own key on that exit. Reporting after would say "started clean" about a launch
|
|
127
136
|
// that did carry a conversation, and the transcript (which shows the crash) would disagree.
|
|
128
137
|
const resumed = session.resumedConversation;
|
|
138
|
+
// Read alongside `resumed`, and BEFORE `start()` for the same reason: a bad `--resume` that
|
|
139
|
+
// kills the process on spawn clears the engine's key, and a seed answer read afterwards would
|
|
140
|
+
// describe a different launch from the one the caller asked about.
|
|
141
|
+
const seeded = session.seededConversation;
|
|
129
142
|
session.start();
|
|
130
|
-
return { ...this.snapshot(input.sessionId), resumed };
|
|
143
|
+
return { ...this.snapshot(input.sessionId), resumed, seeded };
|
|
131
144
|
}
|
|
132
145
|
/**
|
|
133
146
|
* The pane the brain reasons over + the inferred run state.
|
|
@@ -153,6 +166,8 @@ export class CodingRuntime {
|
|
|
153
166
|
// exactly the question asked about a session that just stopped.
|
|
154
167
|
authResolved: session.authResolved,
|
|
155
168
|
engineRuntime: session.engineRuntime,
|
|
169
|
+
engineMode: session.engineMode,
|
|
170
|
+
engineModeWarning: session.engineModeWarning,
|
|
156
171
|
// Reported on EVERY capture, including one where the session is not alive: "how did the
|
|
157
172
|
// last turn end" is exactly the question asked about a session that just stopped, and
|
|
158
173
|
// the omitted-when-null shape keeps "not measured" distinguishable from a verdict.
|
|
@@ -247,6 +262,8 @@ export class CodingRuntime {
|
|
|
247
262
|
takeover: this.takeovers.has(sessionId),
|
|
248
263
|
authResolved: s.authResolved,
|
|
249
264
|
engineRuntime: s.engineRuntime,
|
|
265
|
+
engineMode: s.engineMode,
|
|
266
|
+
engineModeWarning: s.engineModeWarning,
|
|
250
267
|
ghGuard: s.ghGuard,
|
|
251
268
|
}));
|
|
252
269
|
}
|
|
@@ -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,6 +331,18 @@ async function route(runner, req, res) {
|
|
|
240
331
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
241
332
|
}
|
|
242
333
|
}
|
|
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
|
+
}
|
|
243
346
|
// The ONE write surface (#322). A standing policy may put a checkout back on the branch it
|
|
244
347
|
// declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
|
|
245
348
|
// cloud reports as "asked, not confirmed" rather than as done.
|