@sweny-ai/core 0.1.176 → 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.
- package/dist/agent-env.d.ts +9 -0
- package/dist/agent-env.js +4 -0
- package/dist/cli/comment-output.js +4 -5
- package/dist/cli/config-file.d.ts +15 -1
- package/dist/cli/config-file.js +26 -3
- package/dist/cli/final-output.d.ts +4 -1
- package/dist/cli/final-output.js +6 -8
- package/dist/cli/main.js +14 -4
- package/dist/cli/run-history.js +4 -2
- package/dist/cli/run-output.js +11 -3
- package/dist/cli/try.js +2 -3
- package/dist/executor.d.ts +6 -1
- package/dist/executor.js +55 -10
- package/dist/harness/acp.js +2 -2
- package/dist/harness/claude-code.js +6 -4
- package/dist/harness/codex.js +2 -2
- package/dist/harness/pi.js +2 -2
- package/dist/journal.d.ts +1 -1
- package/dist/journal.js +6 -7
- package/dist/safe-file.d.ts +74 -0
- package/dist/safe-file.js +211 -0
- package/dist/safe-outputs.d.ts +7 -1
- package/dist/safe-outputs.js +7 -1
- package/dist/skills/git-push.d.ts +99 -10
- package/dist/skills/git-push.js +413 -52
- package/dist/skills/github.d.ts +19 -15
- package/dist/skills/github.js +91 -15
- package/dist/startup-env.d.ts +30 -0
- package/dist/startup-env.js +48 -0
- package/dist/types.d.ts +37 -1
- package/package.json +1 -1
package/dist/skills/git-push.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
55
|
-
export function
|
|
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
|
|
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
|
|
327
|
+
* Push the PR's head branch from the sweny process. Never throws.
|
|
72
328
|
*/
|
|
73
329
|
export async function pushHeadBranch(opts) {
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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),
|
|
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
|
-
|
|
132
|
-
|
|
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
|
+
}
|
package/dist/skills/github.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|