@sweny-ai/core 0.1.175 → 0.1.177

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.
@@ -7,26 +7,165 @@
7
7
  * the sweny process, with the github skill's token (`GITHUB_TOKEN`), right
8
8
  * before it opens the PR. The agent never holds the token.
9
9
  *
10
- * Guard rails: only a local branch that exists, only to `origin` when it is
11
- * the PR's own repo, never force, never the base branch or the remote's
12
- * default branch. The token goes to git through command-scope config in the
13
- * child env (`GIT_CONFIG_*`), never argv, as an `http.<server>/.extraheader`
14
- * that first resets any persisted one (so the job token is never sent twice).
10
+ * The checkout is agent-written, so nothing in it may run, or steer the push,
11
+ * while the token is in play. The push therefore never runs in the checkout:
12
+ *
13
+ * 1. Read-only git in the checkout, with no token in its env, resolves the
14
+ * head commit, checks that every fetch and push URL of `origin` is the PR's
15
+ * repo on the GitHub server, and finds the object store.
16
+ * 2. A private temporary bare repo borrows those objects (`alternates`, data
17
+ * only) and gets one ref, the head commit.
18
+ * 3. git pushes from that repo, with no system or global config, an empty
19
+ * hooks dir, `--no-verify` and no credential helper, to an explicit URL
20
+ * built from the PR's repo (`<server>/<owner>/<repo>.git`) with an explicit
21
+ * refspec. No agent-written config, hook, push URL or `insteadOf` is read.
22
+ * The token goes to git as command-scope config in the child env, never
23
+ * argv, and only on this one invocation.
24
+ *
25
+ * The git binary, its PATH, the proxy and CA settings and the server URL come
26
+ * from the environment captured at process startup (startup-env.ts), never
27
+ * from a value the workspace `.env` introduced, and git is run by an absolute
28
+ * path that lies outside the workspace, the temp dir and any directory this
29
+ * user can write (see {@link resolveTrustedGit}).
30
+ *
31
+ * The private repo lives in the temp dir, which this user (and so the agent)
32
+ * can write. A process the agent left running could edit its config between
33
+ * `init` and `push` (an `insteadOf`, a proxy, `sslVerify=false`). Two layers:
34
+ * the push carries command-scope config for its exact URL (proxy from the
35
+ * trusted env or none, `sslVerify=true`, CA only from the trusted env), which
36
+ * beats repo-local config of the same URL; and the repo's config, alternates,
37
+ * top-level entries and hooks dir are fingerprinted after setup and compared
38
+ * right before the push, which is refused on any change. git has no switch to
39
+ * skip `$GIT_DIR/config`, so a byte check is the idiomatic guard. Residual
40
+ * risk: a change landing in the milliseconds between that check and git's own
41
+ * config read. The harnesses stop the agent's process (Codex its whole process
42
+ * group) when a node ends, but a deliberately detached process (`setsid`)
43
+ * escapes any group kill; only the sandbox's own process boundary ends it.
44
+ *
45
+ * Guard rails: only a local branch that exists, only when origin is the PR's
46
+ * own repo, never the base branch or the repo's default branch (from the
47
+ * GitHub API, not the checkout's `origin/HEAD`). The head must be new on the
48
+ * remote, or an unprotected branch with an open PR into the base (checked by
49
+ * the caller through the API); the push leases on the sha that check saw and
50
+ * only fast-forwards.
15
51
  * A push that fails is logged and the PR is still requested: the agent may
16
52
  * already have pushed (persisted credentials, an `agent_env` grant).
17
53
  */
18
54
  import { execFile } from "node:child_process";
19
- import { setBranchPusher } from "./github.js";
20
- const defaultGit = (args, opts) => new Promise((resolve) => {
21
- execFile("git", args, { cwd: opts.cwd, env: opts.env, timeout: 120_000 }, (err, stdout, stderr) => {
22
- const code = err
23
- ? typeof err.code === "number"
24
- ? err.code
25
- : 1
26
- : 0;
27
- resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
55
+ import { accessSync, constants as fsConstants, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
56
+ import { devNull } from "node:os";
57
+ import * as path from "node:path";
58
+ import { startupEnv, startupTmpdir, trustedEnvValue } from "../startup-env.js";
59
+ /** True when `child` is `dir` or inside it. */
60
+ function inside(dir, child) {
61
+ const rel = path.relative(dir, child);
62
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
63
+ }
64
+ function real(p) {
65
+ try {
66
+ return realpathSync(p);
67
+ }
68
+ catch {
69
+ return path.resolve(p);
70
+ }
71
+ }
72
+ /** True when this user can write `dir` or any directory above it. */
73
+ function writableChain(dir) {
74
+ for (let d = path.resolve(dir);; d = path.dirname(d)) {
75
+ try {
76
+ accessSync(d, fsConstants.W_OK);
77
+ return true;
78
+ }
79
+ catch {
80
+ // Not writable: check the parent.
81
+ }
82
+ if (path.dirname(d) === d)
83
+ return false;
84
+ }
85
+ }
86
+ /**
87
+ * Whether `dir` may hold the git sweny runs with the token: absolute, outside
88
+ * the workspace and the temp dir, and not writable by this user at any level
89
+ * (the agent runs as this user). The writability check is skipped as root,
90
+ * where the agent could replace any binary anyway, and on Windows, where
91
+ * `access(W_OK)` only reads the read-only attribute: there the sandbox is the
92
+ * boundary.
93
+ */
94
+ function trustedDir(dir, workspace) {
95
+ if (!dir || !path.isAbsolute(dir))
96
+ return false;
97
+ const r = real(dir);
98
+ const ws = real(workspace);
99
+ const tmp = real(startupTmpdir());
100
+ for (const d of [path.resolve(dir), r]) {
101
+ if (inside(ws, d) || inside(path.resolve(workspace), d) || inside(tmp, d))
102
+ return false;
103
+ }
104
+ const root = typeof process.getuid === "function" && process.getuid() === 0;
105
+ if (!root && process.platform !== "win32" && (writableChain(dir) || writableChain(r)))
106
+ return false;
107
+ return true;
108
+ }
109
+ /** The trusted entries of a PATH value, in order (see {@link trustedDir}). */
110
+ export function trustedPathDirs(pathValue, workspace) {
111
+ const seen = new Set();
112
+ const out = [];
113
+ for (const d of (pathValue ?? "").split(path.delimiter)) {
114
+ if (seen.has(d) || !trustedDir(d, workspace))
115
+ continue;
116
+ seen.add(d);
117
+ out.push(d);
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * The absolute path of the first `git` on `pathValue` (the startup PATH) whose
123
+ * directory, and whose resolved file and its directory, pass
124
+ * {@link trustedDir}. Undefined when there is none: nothing is pushed.
125
+ */
126
+ export function resolveTrustedGit(pathValue, workspace) {
127
+ const names = process.platform === "win32" ? ["git.exe"] : ["git"];
128
+ for (const dir of trustedPathDirs(pathValue, workspace)) {
129
+ for (const name of names) {
130
+ const candidate = path.join(dir, name);
131
+ try {
132
+ if (!statSync(candidate).isFile())
133
+ continue;
134
+ accessSync(candidate, fsConstants.X_OK);
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ const file = real(candidate);
140
+ if (!trustedDir(path.dirname(file), workspace))
141
+ continue;
142
+ if (process.platform !== "win32" && !(typeof process.getuid === "function" && process.getuid() === 0)) {
143
+ try {
144
+ accessSync(file, fsConstants.W_OK);
145
+ continue; // A git binary this user can rewrite is not trusted.
146
+ }
147
+ catch {
148
+ // Not writable: trusted.
149
+ }
150
+ }
151
+ return candidate;
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ /** A runner that executes the git at `gitPath` (absolute), never a PATH lookup. */
157
+ export function gitAt(gitPath) {
158
+ return (args, opts) => new Promise((resolve) => {
159
+ execFile(gitPath, args, { cwd: opts.cwd, env: opts.env, timeout: 120_000 }, (err, stdout, stderr) => {
160
+ const code = err
161
+ ? typeof err.code === "number"
162
+ ? err.code
163
+ : 1
164
+ : 0;
165
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
166
+ });
28
167
  });
29
- });
168
+ }
30
169
  /** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
31
170
  export function repoFromRemote(url) {
32
171
  const u = url.trim();
@@ -51,15 +190,18 @@ export function isSafeBranch(name) {
51
190
  !name.includes("..") &&
52
191
  !name.includes("//"));
53
192
  }
54
- /** The command-scope git config that authenticates one push to `server` with `token`. */
55
- export function pushAuthConfig(server, token, env) {
193
+ /** `owner/repo` with plain name characters only. */
194
+ export function isSafeRepo(repo) {
195
+ const parts = repo.split("/");
196
+ return (parts.length === 2 &&
197
+ parts.every((p) => /^[A-Za-z0-9._-]+$/.test(p) && p !== "." && p !== ".." && !p.startsWith("-")));
198
+ }
199
+ /** `env` plus command-scope config entries (GIT_CONFIG_COUNT/KEY/VALUE), appended after any already there. */
200
+ function withCommandConfig(env, entries) {
56
201
  const out = { ...env };
57
202
  const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
58
203
  let n = Number.isFinite(base) && base > 0 ? base : 0;
59
- const key = `http.${server.replace(/\/+$/, "")}/.extraheader`;
60
- const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
61
- // An empty value resets the list, so a header the checkout persisted is not sent too.
62
- for (const value of ["", `AUTHORIZATION: basic ${basic}`]) {
204
+ for (const [key, value] of entries) {
63
205
  out[`GIT_CONFIG_KEY_${n}`] = key;
64
206
  out[`GIT_CONFIG_VALUE_${n}`] = value;
65
207
  n++;
@@ -67,50 +209,257 @@ export function pushAuthConfig(server, token, env) {
67
209
  out.GIT_CONFIG_COUNT = String(n);
68
210
  return out;
69
211
  }
212
+ /** The command-scope git config that authenticates one push to `server` with `token`. */
213
+ export function pushAuthConfig(server, token, env) {
214
+ const key = `http.${server.replace(/\/+$/, "")}/.extraheader`;
215
+ const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
216
+ // An empty value resets the list, so no other header is sent with it.
217
+ return withCommandConfig(env, [
218
+ [key, ""],
219
+ [key, `AUTHORIZATION: basic ${basic}`],
220
+ ]);
221
+ }
222
+ /**
223
+ * Command-scope transport config for the push to `destination`, keyed to that
224
+ * exact URL so it beats repo-local `http.*` and `http.<url>.*` entries: the
225
+ * proxy from the trusted env or none (an empty `http.proxy` disables proxying,
226
+ * env included), TLS verification on, and a CA bundle only from the trusted env.
227
+ */
228
+ export function pushTransportConfig(destination, trusted) {
229
+ const k = (name) => `http.${destination}.${name}`;
230
+ const proxy = trusted.HTTPS_PROXY ?? trusted.https_proxy ?? trusted.ALL_PROXY ?? trusted.all_proxy ?? "";
231
+ const out = [
232
+ [k("proxy"), proxy],
233
+ [k("sslVerify"), "true"],
234
+ ];
235
+ if (trusted.GIT_SSL_CAINFO)
236
+ out.push([k("sslCAInfo"), trusted.GIT_SSL_CAINFO]);
237
+ if (trusted.GIT_SSL_CAPATH)
238
+ out.push([k("sslCAPath"), trusted.GIT_SSL_CAPATH]);
239
+ return out;
240
+ }
241
+ // What the operator's env may hand git: locale, home, temp dirs, proxies and
242
+ // CA bundles, each through the startup snapshot (trustedEnvValue). PATH is set
243
+ // by the caller from the trusted startup PATH. Every other GIT_* setting
244
+ // (GIT_DIR, GIT_CONFIG_*, GIT_SSH*, GIT_ASKPASS, GIT_TRACE*, ...) is dropped.
245
+ const KEPT_ENV = /^(HOME|USERPROFILE|TMPDIR|TEMP|TMP|LANG|LANGUAGE|LC_[A-Z_]+|SYSTEMROOT|SystemRoot|COMSPEC|ComSpec|PATHEXT|(HTTPS?|ALL|NO)_PROXY|(https?|all|no)_proxy|SSL_CERT_FILE|SSL_CERT_DIR|GIT_SSL_CAINFO|GIT_SSL_CAPATH)$/;
246
+ /**
247
+ * The env every sweny-side git call runs with: the trusted PATH, no system or
248
+ * global config, no prompts, no inherited git settings, and no kept value the
249
+ * workspace `.env` introduced.
250
+ */
251
+ export function hardenedGitEnv(env, trustedPath) {
252
+ const out = {};
253
+ const keys = new Set([...Object.keys(env === process.env ? startupEnv() : env), ...Object.keys(startupEnv())]);
254
+ for (const k of keys) {
255
+ if (!KEPT_ENV.test(k))
256
+ continue;
257
+ const v = trustedEnvValue(env, k);
258
+ if (v !== undefined)
259
+ out[k] = v;
260
+ }
261
+ if (trustedPath !== undefined)
262
+ out.PATH = trustedPath;
263
+ out.GIT_CONFIG_NOSYSTEM = "1";
264
+ out.GIT_CONFIG_GLOBAL = devNull;
265
+ out.GIT_TERMINAL_PROMPT = "0";
266
+ return out;
267
+ }
268
+ /**
269
+ * True when a fresh `git init --bare` config holds only repo-format keys
270
+ * (`[core]` format/fs flags, `[extensions]` object format and ref storage).
271
+ */
272
+ export function isPlainRepoConfig(text) {
273
+ let section = "";
274
+ for (const raw of text.split("\n")) {
275
+ const line = raw.trim();
276
+ if (!line || line.startsWith("#") || line.startsWith(";"))
277
+ continue;
278
+ const head = /^\[([A-Za-z0-9.-]+)\]$/.exec(line);
279
+ if (head) {
280
+ section = head[1].toLowerCase();
281
+ if (section !== "core" && section !== "extensions")
282
+ return false;
283
+ continue;
284
+ }
285
+ const kv = /^([A-Za-z][A-Za-z0-9-]*)\s*=\s*[A-Za-z0-9._-]*$/.exec(line);
286
+ if (!kv)
287
+ return false;
288
+ const key = `${section}.${kv[1].toLowerCase()}`;
289
+ if (!PLAIN_REPO_KEYS.has(key))
290
+ return false;
291
+ }
292
+ return true;
293
+ }
294
+ const PLAIN_REPO_KEYS = new Set([
295
+ "core.repositoryformatversion",
296
+ "core.filemode",
297
+ "core.bare",
298
+ "core.ignorecase",
299
+ "core.precomposeunicode",
300
+ "core.symlinks",
301
+ "core.logallrefupdates",
302
+ "extensions.objectformat",
303
+ "extensions.refstorage",
304
+ ]);
305
+ /** Command-line config (highest precedence) for every sweny-side git call. */
306
+ function hardenedArgs(hooksDir) {
307
+ return [
308
+ "-c",
309
+ `core.hooksPath=${hooksDir}`,
310
+ "-c",
311
+ "core.fsmonitor=false",
312
+ "-c",
313
+ "core.sshCommand=",
314
+ "-c",
315
+ "credential.helper=",
316
+ "-c",
317
+ "core.askPass=",
318
+ "-c",
319
+ "protocol.ext.allow=never",
320
+ ];
321
+ }
322
+ /** A branch name without a leading `refs/heads/`, so `refs/heads/develop` and `develop` compare equal. */
323
+ export function branchName(ref) {
324
+ return ref.trim().replace(/^refs\/heads\//, "");
325
+ }
70
326
  /**
71
- * Push the PR's head branch to `origin` from the sweny process. Never throws.
327
+ * Push the PR's head branch from the sweny process. Never throws.
72
328
  */
73
329
  export async function pushHeadBranch(opts) {
74
- const git = opts.git ?? defaultGit;
75
- const cwd = opts.cwd ?? process.cwd();
76
- const env = { ...(opts.env ?? process.env), GIT_TERMINAL_PROMPT: "0" };
77
- const { head, base } = opts;
330
+ const { token } = opts;
331
+ const head = branchName(opts.head);
332
+ const base = branchName(opts.base);
333
+ const defaultBranch = opts.defaultBranch === undefined ? undefined : branchName(opts.defaultBranch);
334
+ if (!isSafeRepo(opts.repo))
335
+ return { pushed: false, attempted: false, reason: `repo "${opts.repo}" is not owner/repo` };
78
336
  if (!isSafeBranch(head))
79
337
  return { pushed: false, attempted: false, reason: `head "${head}" is not a plain branch name` };
80
338
  if (head === base)
81
339
  return { pushed: false, attempted: false, reason: "head is the base branch" };
340
+ if (!defaultBranch)
341
+ return { pushed: false, attempted: false, reason: "the repo's default branch is unknown" };
342
+ if (head === defaultBranch || head === "main" || head === "master") {
343
+ return { pushed: false, attempted: false, reason: `head ${head} is the default branch` };
344
+ }
345
+ const remoteSha = opts.remoteSha;
346
+ if (remoteSha === undefined || !/^([0-9a-f]{40}([0-9a-f]{24})?)?$/.test(remoteSha)) {
347
+ return { pushed: false, attempted: false, reason: `the remote state of ${head} is unknown` };
348
+ }
349
+ if (!token)
350
+ return { pushed: false, attempted: false, reason: "no GITHUB_TOKEN" };
351
+ const opEnv = opts.env ?? process.env;
352
+ const cwd = path.resolve(opts.cwd);
353
+ // git and its PATH come from the startup snapshot, never the run's env or the workspace.
354
+ const startupPath = startupEnv().PATH ?? startupEnv().Path;
355
+ const gitPath = opts.git ? undefined : resolveTrustedGit(startupPath, cwd);
356
+ if (!opts.git && !gitPath) {
357
+ return { pushed: false, attempted: false, reason: "no git outside the workspace and writable dirs" };
358
+ }
359
+ const git = opts.git ?? gitAt(gitPath);
360
+ let server;
361
+ try {
362
+ server = new URL((trustedEnvValue(opEnv, "GITHUB_SERVER_URL") || "https://github.com").replace(/\/+$/, ""));
363
+ }
364
+ catch {
365
+ return { pushed: false, attempted: false, reason: "GITHUB_SERVER_URL is not a URL" };
366
+ }
367
+ if (server.protocol !== "https:" || server.username || server.password || server.search || server.hash) {
368
+ return { pushed: false, attempted: false, reason: "GITHUB_SERVER_URL is not a plain https URL" };
369
+ }
370
+ const serverBase = `${server.origin}${server.pathname.replace(/\/+$/, "")}`;
371
+ const serverHost = server.hostname.toLowerCase();
372
+ const destination = `${serverBase}/${opts.repo}.git`;
373
+ const env = hardenedGitEnv(opEnv, trustedPathDirs(startupPath, cwd).join(path.delimiter));
374
+ let tmp;
82
375
  try {
83
- const local = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${head}^{commit}`], { cwd, env });
84
- if (local.code !== 0)
376
+ tmp = mkdtempSync(path.join(startupTmpdir(), "sweny-push-"));
377
+ const hooks = path.join(tmp, "hooks");
378
+ mkdirSync(hooks, { mode: 0o700 });
379
+ const hard = hardenedArgs(hooks);
380
+ // Reads in the agent's checkout: no token in the env, nothing run from its config.
381
+ const read = (args) => git([...hard, "-c", "safe.directory=*", ...args], { cwd, env });
382
+ const local = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${head}^{commit}`]);
383
+ const sha = local.stdout.trim();
384
+ if (local.code !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(sha)) {
85
385
  return { pushed: false, attempted: false, reason: `no local branch ${head}` };
86
- const remote = await git(["remote", "get-url", "origin"], { cwd, env });
87
- if (remote.code !== 0)
386
+ }
387
+ const fetchUrls = await read(["remote", "get-url", "--all", "origin"]);
388
+ const pushUrls = await read(["remote", "get-url", "--push", "--all", "origin"]);
389
+ if (fetchUrls.code !== 0 || pushUrls.code !== 0) {
88
390
  return { pushed: false, attempted: false, reason: "no origin remote" };
89
- const parsed = repoFromRemote(remote.stdout);
90
- if (!parsed || parsed.repo !== opts.repo.toLowerCase()) {
91
- return { pushed: false, attempted: false, reason: `origin is not ${opts.repo}` };
92
391
  }
93
- const originHead = await git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { cwd, env });
94
- const defaultBranch = originHead.code === 0 ? originHead.stdout.trim().replace(/^origin\//, "") : undefined;
95
- if (head === defaultBranch || head === "main" || head === "master") {
96
- return { pushed: false, attempted: false, reason: `head ${head} is the default branch` };
392
+ const urls = `${fetchUrls.stdout}\n${pushUrls.stdout}`
393
+ .split("\n")
394
+ .map((u) => u.trim())
395
+ .filter(Boolean);
396
+ const wanted = opts.repo.toLowerCase();
397
+ const bad = urls.length === 0 ||
398
+ urls.some((u) => {
399
+ const p = repoFromRemote(u);
400
+ return !p || p.repo !== wanted || p.host !== serverHost;
401
+ });
402
+ if (bad)
403
+ return { pushed: false, attempted: false, reason: `origin is not ${opts.repo} on ${serverHost}` };
404
+ const objectsOut = await read(["rev-parse", "--git-path", "objects"]);
405
+ const shallowOut = await read(["rev-parse", "--git-path", "shallow"]);
406
+ if (objectsOut.code !== 0 || shallowOut.code !== 0) {
407
+ return { pushed: false, attempted: false, reason: "cannot locate the checkout's object store" };
97
408
  }
98
- const server = (env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "");
99
- let serverHost;
100
- try {
101
- serverHost = new URL(server).host.toLowerCase();
409
+ const objects = path.resolve(cwd, objectsOut.stdout.trim());
410
+ const shallow = path.resolve(cwd, shallowOut.stdout.trim());
411
+ // A private bare repo that borrows the checkout's objects and holds one ref.
412
+ const repoDir = path.join(tmp, "push.git");
413
+ const own = (args, extraEnv) => git([...hard, `--git-dir=${repoDir}`, ...args], { cwd: tmp, env: { ...env, ...extraEnv } });
414
+ const init = await git([...hard, "init", "--quiet", "--bare", repoDir], { cwd: tmp, env });
415
+ if (init.code !== 0)
416
+ return { pushed: false, attempted: false, reason: "cannot create the push repo" };
417
+ // The config `init` wrote may only hold plain repo-format keys: no url, http, include or remote.
418
+ if (!isPlainRepoConfig(readFileSync(path.join(repoDir, "config"), "utf8"))) {
419
+ return { pushed: false, attempted: false, reason: "the private push repo changed before the push" };
102
420
  }
103
- catch {
104
- serverHost = "github.com";
421
+ writeFileSync(path.join(repoDir, "objects", "info", "alternates"), `${objects}\n`);
422
+ // A shallow checkout (actions/checkout's default): copy its boundary list. Only a
423
+ // plain, bounded file; a link or FIFO the agent planted is not read.
424
+ const shallowStat = lstatSync(shallow, { throwIfNoEntry: false });
425
+ if (shallowStat?.isFile() && shallowStat.size <= 1 << 20) {
426
+ writeFileSync(path.join(repoDir, "shallow"), readFileSync(shallow));
105
427
  }
106
- const pushEnv = opts.token && parsed.https && parsed.host === serverHost ? pushAuthConfig(server, opts.token, env) : env;
107
- const r = await git(["push", "--porcelain", "origin", `refs/heads/${head}:refs/heads/${head}`], {
108
- cwd,
109
- env: pushEnv,
110
- });
428
+ // What the push reads from the private repo, fixed here and re-checked right before the token is used.
429
+ const fingerprint = () => JSON.stringify([
430
+ readdirSync(repoDir).sort(),
431
+ readFileSync(path.join(repoDir, "config"), "utf8"),
432
+ readFileSync(path.join(repoDir, "objects", "info", "alternates"), "utf8"),
433
+ readdirSync(hooks).sort(),
434
+ ]);
435
+ const staged = fingerprint();
436
+ const ref = await own(["update-ref", `refs/heads/${head}`, sha]);
437
+ if (ref.code !== 0)
438
+ return { pushed: false, attempted: false, reason: `cannot stage ${head} for the push` };
439
+ // An existing remote head (an open PR's branch) is only fast-forwarded:
440
+ // the lease below would otherwise let a diverged commit replace it.
441
+ if (remoteSha) {
442
+ const ff = await own(["merge-base", "--is-ancestor", remoteSha, sha]);
443
+ if (ff.code !== 0) {
444
+ return { pushed: false, attempted: false, reason: `${head} is not a fast-forward of the remote ${head}` };
445
+ }
446
+ }
447
+ const pushEnv = pushAuthConfig(serverBase, token, withCommandConfig({}, pushTransportConfig(destination, env)));
448
+ if (fingerprint() !== staged) {
449
+ return { pushed: false, attempted: false, reason: "the private push repo changed before the push" };
450
+ }
451
+ const r = await own([
452
+ "push",
453
+ "--no-verify",
454
+ "--porcelain",
455
+ // The remote head must still be what the API reported ("" = must not exist).
456
+ `--force-with-lease=refs/heads/${head}:${remoteSha}`,
457
+ destination,
458
+ `refs/heads/${head}:refs/heads/${head}`,
459
+ ], pushEnv);
111
460
  if (r.code !== 0) {
112
461
  const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
113
- return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, opts.token)}` };
462
+ return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}` };
114
463
  }
115
464
  return { pushed: true, attempted: true };
116
465
  }
@@ -118,9 +467,19 @@ export async function pushHeadBranch(opts) {
118
467
  return {
119
468
  pushed: false,
120
469
  attempted: false,
121
- reason: redact(err instanceof Error ? err.message : String(err), opts.token),
470
+ reason: redact(err instanceof Error ? err.message : String(err), token),
122
471
  };
123
472
  }
473
+ finally {
474
+ if (tmp) {
475
+ try {
476
+ rmSync(tmp, { recursive: true, force: true });
477
+ }
478
+ catch {
479
+ // Best effort: a leftover temp dir holds no secret.
480
+ }
481
+ }
482
+ }
124
483
  }
125
484
  function redact(text, token) {
126
485
  if (!token)
@@ -128,5 +487,7 @@ function redact(text, token) {
128
487
  const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
129
488
  return text.split(token).join("***").split(basic).join("***");
130
489
  }
131
- // The Node entry imports this module (executor.ts), which arms the push in `github_create_pr`.
132
- setBranchPusher(pushHeadBranch);
490
+ /** A pusher bound to one run's checkout and env (handed to tools as `ToolContext.pushBranch`). */
491
+ export function bindBranchPusher(cwd, env) {
492
+ return (o) => pushHeadBranch({ ...o, cwd, env });
493
+ }
@@ -4,24 +4,28 @@
4
4
  * Replaces: source-control/github.ts + issue-tracking/github-issues.ts
5
5
  * ~800 lines → ~120 lines
6
6
  */
7
- import type { Skill } from "../types.js";
7
+ import type { Skill, ToolContext } from "../types.js";
8
+ export type { BranchPusher } from "../types.js";
8
9
  /**
9
- * Pushes a PR's head branch from the sweny process (#473). The Node entry
10
- * registers it (skills/git-push.ts, imported by the executor); this module
11
- * stays browser-safe, and without a pusher `github_create_pr` only calls the API.
10
+ * The REST base: `ctx.githubApiUrl` (the operator's GITHUB_API_URL, for GHES)
11
+ * or https://api.github.com. Only a plain https URL: no credentials, query or
12
+ * fragment, since the token goes to it.
12
13
  */
13
- export type BranchPusher = (opts: {
14
- repo: string;
15
- head: string;
16
- base: string;
17
- token?: string;
18
- }) => Promise<{
19
- pushed: boolean;
20
- attempted: boolean;
21
- reason?: string;
14
+ export declare function githubApiBase(apiUrl: string | undefined): string;
15
+ /**
16
+ * Whether sweny may push the PR's head branch, from the API (#498). The agent
17
+ * names head and base, so head could be an existing integration branch
18
+ * ("release") that is neither base nor default. A head that does not exist
19
+ * remotely is pushed with an empty lease (it must still not exist). One that
20
+ * exists is pushed only when it is unprotected and already has an open PR from
21
+ * this repo into `base`, leased on the sha the API returned. Any API failure
22
+ * means no push.
23
+ */
24
+ export declare function headPushLease(repo: string, head: string, base: string, ctx: ToolContext): Promise<{
25
+ remoteSha: string;
26
+ } | {
27
+ skip: string;
22
28
  }>;
23
- /** Register (or clear, with `undefined`) the sweny-side branch pusher. */
24
- export declare function setBranchPusher(fn: BranchPusher | undefined): void;
25
29
  /**
26
30
  * Encode an LLM-chosen repo file path for safe interpolation into a
27
31
  * GitHub API request path.