@proagentstore/cli 0.4.47 → 0.4.48
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,128 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { InspectError } from "./inspect.js";
|
|
5
|
+
/**
|
|
6
|
+
* May this string become a git token?
|
|
7
|
+
*
|
|
8
|
+
* An allowlist, not a denylist: `execFile` means there is no shell to escape, but a name beginning
|
|
9
|
+
* `-` would still be read by git as an OPTION, which is the one injection this argv shape is open
|
|
10
|
+
* to. The rest is `git check-ref-format` reduced to the subset a real branch uses.
|
|
11
|
+
*/
|
|
12
|
+
export function isSwitchableBranchName(name) {
|
|
13
|
+
if (typeof name !== "string")
|
|
14
|
+
return false;
|
|
15
|
+
const n = name.trim();
|
|
16
|
+
if (!n || n.length > 200)
|
|
17
|
+
return false;
|
|
18
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(n))
|
|
19
|
+
return false; // no leading `-` or `.`, no spaces, no `~^:?*[\`
|
|
20
|
+
if (n.includes("..") || n.includes("//") || n.endsWith("/") || n.endsWith(".lock") || n.includes("@{"))
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Map the enum to a fixed argv. The branch is the only caller-supplied token, and it is validated. */
|
|
25
|
+
export function gitWriteArgv(cmd, opts = {}) {
|
|
26
|
+
switch (cmd) {
|
|
27
|
+
case "switch-branch": {
|
|
28
|
+
if (!isSwitchableBranchName(opts.branch))
|
|
29
|
+
throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
|
|
30
|
+
// `--` terminates option parsing AND says "what follows is a ref, not a path", so a branch
|
|
31
|
+
// that shares a name with a file cannot turn this into a file checkout — which WOULD
|
|
32
|
+
// discard work.
|
|
33
|
+
return ["checkout", opts.branch.trim(), "--"];
|
|
34
|
+
}
|
|
35
|
+
default:
|
|
36
|
+
throw new InspectError(`unsupported git write command: ${cmd}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function git(workDir, argv) {
|
|
40
|
+
// stderr is PIPED rather than inherited: git narrates a checkout on stderr, and the runner's
|
|
41
|
+
// console is a user-facing log, not a place for `Switched to branch 'main'`. Piping it is also
|
|
42
|
+
// what makes `e.stderr` available, which is the only honest sentence to put on the card when git
|
|
43
|
+
// itself refuses.
|
|
44
|
+
return execFileSync("git", argv, {
|
|
45
|
+
cwd: workDir,
|
|
46
|
+
encoding: "utf-8",
|
|
47
|
+
timeout: 15_000,
|
|
48
|
+
maxBuffer: 1024 * 1024,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
}).toString();
|
|
51
|
+
}
|
|
52
|
+
/** The current branch, or the short SHA when detached, or null when even that fails. */
|
|
53
|
+
function currentBranch(workDir) {
|
|
54
|
+
try {
|
|
55
|
+
const name = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
56
|
+
if (name && name !== "HEAD")
|
|
57
|
+
return name;
|
|
58
|
+
return git(workDir, ["rev-parse", "--short", "HEAD"]).trim() || null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isDirty(workDir) {
|
|
65
|
+
// `--porcelain` alone: untracked files COUNT as dirty here, because they are exactly the ones a
|
|
66
|
+
// checkout would carry across without git saying a word about it.
|
|
67
|
+
return git(workDir, ["status", "--porcelain"]).trim().length > 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Put the checkout back on `branch`, or refuse and change nothing.
|
|
71
|
+
*
|
|
72
|
+
* Never creates a branch, never fetches, never touches the remote: the target must already exist
|
|
73
|
+
* locally, because inventing one would be deciding what should be true rather than restoring what
|
|
74
|
+
* was declared. Every refusal path returns BEFORE any write.
|
|
75
|
+
*
|
|
76
|
+
* The repo test is `.git` at THIS path, not `git rev-parse --is-inside-work-tree` — the opposite of
|
|
77
|
+
* the choice #405 made for the read side, and deliberately so. `rev-parse` answers yes from a
|
|
78
|
+
* subdirectory, so a workdir that happens to sit inside a larger checkout (`~/dev/stores` is one)
|
|
79
|
+
* would have its ENCLOSING repo switched by a policy declared on something else. A write acts only
|
|
80
|
+
* on the repo the owner actually named; the cost is a refusal on a subdirectory workdir, which is
|
|
81
|
+
* visible on the card and safe.
|
|
82
|
+
*/
|
|
83
|
+
export function switchRepoBranch(workDir, branch) {
|
|
84
|
+
if (!isSwitchableBranchName(branch))
|
|
85
|
+
throw new InspectError(`unusable branch name: ${String(branch)}`);
|
|
86
|
+
const to = branch.trim();
|
|
87
|
+
const base = { ok: false, changed: false, from: null, to, branch: null, dirty: false };
|
|
88
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
89
|
+
return { ...base, refused: "not-a-repo" };
|
|
90
|
+
const from = currentBranch(workDir);
|
|
91
|
+
if (!from)
|
|
92
|
+
return { ...base, refused: "unknown-head" };
|
|
93
|
+
let dirty;
|
|
94
|
+
try {
|
|
95
|
+
dirty = isDirty(workDir);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
return { ...base, from, branch: from, error: e.message?.slice(0, 200) };
|
|
99
|
+
}
|
|
100
|
+
// THE PRECONDITION. Uncommitted work rides along through a checkout; refusing is the only
|
|
101
|
+
// answer that cannot move somebody's diff onto a branch they did not put it on.
|
|
102
|
+
if (dirty)
|
|
103
|
+
return { ...base, from, branch: from, dirty: true, refused: "dirty" };
|
|
104
|
+
if (from === to)
|
|
105
|
+
return { ok: true, changed: false, from, to, branch: from, dirty: false };
|
|
106
|
+
try {
|
|
107
|
+
git(workDir, ["rev-parse", "--verify", "--quiet", `refs/heads/${to}`]);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { ...base, from, branch: from, refused: "unknown-branch" };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
git(workDir, gitWriteArgv("switch-branch", { branch: to }));
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const err = e;
|
|
117
|
+
return { ...base, from, branch: currentBranch(workDir), error: (err.stderr || err.message || "git checkout failed").slice(0, 200) };
|
|
118
|
+
}
|
|
119
|
+
// CONFIRM, do not assume. The exit code says the command ran; only reading HEAD back says where
|
|
120
|
+
// the checkout actually is, and that is the only thing the cloud is allowed to report as done.
|
|
121
|
+
const after = currentBranch(workDir);
|
|
122
|
+
let dirtyAfter = false;
|
|
123
|
+
try {
|
|
124
|
+
dirtyAfter = isDirty(workDir);
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
128
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { RunnerInputError } from "../errors.js";
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
5
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
6
|
+
import { switchRepoBranch } from "./repo-write.js";
|
|
6
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
7
8
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
8
9
|
const MAX_PANE = 64 * 1024;
|
|
@@ -24,7 +25,11 @@ export class CodingRuntime {
|
|
|
24
25
|
* read-only code-inspection endpoints exist, so the cloud offers the grounding tools
|
|
25
26
|
* (older runners omit it → the cloud degrades to terminal-only). */
|
|
26
27
|
static capabilities() {
|
|
27
|
-
|
|
28
|
+
// `coding.repo-write` announces the ONE write verb (#322). Advertised rather than probed, so
|
|
29
|
+
// a reader of the registration can see which machines can restore a branch invariant; the
|
|
30
|
+
// cloud still never trusts it, because an older runner simply 404s and the policy reports
|
|
31
|
+
// that it asked and was not answered.
|
|
32
|
+
return ["coding.sessions", "coding.stream", "human.takeover", "coding.inspect", "coding.repo-write"];
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Resolve the workDir for a read-only inspection. Prefer the tracked session's real
|
|
@@ -50,6 +55,16 @@ export class CodingRuntime {
|
|
|
50
55
|
git(input) {
|
|
51
56
|
return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
|
|
52
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
|
|
60
|
+
* it declared, or refuse. See `repo-write.ts`: fixed argv, clean tree required, nothing in the
|
|
61
|
+
* `checkout .`/`reset`/`clean`/`stash` family exists to be reached.
|
|
62
|
+
*/
|
|
63
|
+
gitWrite(input) {
|
|
64
|
+
if (input.cmd !== "switch-branch")
|
|
65
|
+
throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
|
|
66
|
+
return switchRepoBranch(this.resolveWorkDir(input), input.branch);
|
|
67
|
+
}
|
|
53
68
|
/** Bounded recursive file tree of the session's repo (names/type/size only). */
|
|
54
69
|
tree(input) {
|
|
55
70
|
return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
|
|
@@ -223,6 +223,18 @@ async function route(runner, req, res) {
|
|
|
223
223
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
// The ONE write surface (#322). A standing policy may put a checkout back on the branch it
|
|
227
|
+
// declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
|
|
228
|
+
// cloud reports as "asked, not confirmed" rather than as done.
|
|
229
|
+
if (req.method === "POST" && path === "/coding/git-write") {
|
|
230
|
+
const b = await readJson(req);
|
|
231
|
+
try {
|
|
232
|
+
return json(res, 200, runner.coding.gitWrite(b));
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
226
238
|
if (req.method === "POST" && path === "/coding/git-remote") {
|
|
227
239
|
const b = await readJson(req);
|
|
228
240
|
try {
|