@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.
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the sweny process writes into an agent-writable workspace.
|
|
3
|
+
*
|
|
4
|
+
* An agent can leave a symlink or a hard link where sweny is about to write
|
|
5
|
+
* (`.sweny/runs/<id>/output.md` -> `~/.bashrc`), or swap a directory on the
|
|
6
|
+
* path for a link. These helpers never follow a link: every directory between
|
|
7
|
+
* the trusted root and the file must be a real directory owned by this user,
|
|
8
|
+
* the final component is opened `O_NOFOLLOW`, and the file written must be a
|
|
9
|
+
* regular file with one link (a replaced link or special file is unlinked and
|
|
10
|
+
* created fresh with `O_EXCL`). Data goes through the descriptor that was
|
|
11
|
+
* checked; the mode is set on it.
|
|
12
|
+
*
|
|
13
|
+
* Race with a process that swaps a checked directory for a link (TOCTOU):
|
|
14
|
+
* Node has no `openat(2)`, so the parent chain is checked by pathname and the
|
|
15
|
+
* file opened by pathname. After the open, {@link assertOpenedUnder} re-checks
|
|
16
|
+
* the chain (realpath equals the lexical path, every component a real owned
|
|
17
|
+
* directory) and requires that the pathname now names the very inode the
|
|
18
|
+
* descriptor holds (dev and ino). A swap before the open either leaves a link
|
|
19
|
+
* on the path or moves the opened inode off it; both fail closed before a byte
|
|
20
|
+
* is written. Residual risk: an `O_CREAT` through a swapped directory can leave
|
|
21
|
+
* an empty file outside the workspace before the check fails, and the unlink of
|
|
22
|
+
* a planted link or special file is by pathname (checked just before, not
|
|
23
|
+
* atomic). A regular file is never unlinked or truncated by pathname.
|
|
24
|
+
*
|
|
25
|
+
* Node only.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Create `dir` and its missing parents below `root`, checking each component
|
|
29
|
+
* under `root` with lstat: a real directory, owned by this user. `root` itself
|
|
30
|
+
* is trusted. A `dir` outside `root` is created as usual and only its last
|
|
31
|
+
* component is checked.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ensureDirNoFollow(dir: string, root: string, mode?: number): void;
|
|
34
|
+
export interface NoFollowOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Trusted base dir (the workspace root): every component below it, and the
|
|
37
|
+
* file, must be owned by this user. Default: the file's own dir, with no
|
|
38
|
+
* ownership check (a runner-owned GITHUB_STEP_SUMMARY, say).
|
|
39
|
+
*/
|
|
40
|
+
root?: string;
|
|
41
|
+
/** File mode, set on the descriptor. Default 0o600. */
|
|
42
|
+
mode?: number;
|
|
43
|
+
/** Mode for directories created on the way. Default 0o700. */
|
|
44
|
+
dirMode?: number;
|
|
45
|
+
/** Append to an existing regular file instead of replacing it. */
|
|
46
|
+
append?: boolean;
|
|
47
|
+
/** Create missing directories. Default true. */
|
|
48
|
+
mkdirs?: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Open `file` for writing without following a link. Replace mode unlinks what
|
|
52
|
+
* is there and creates a fresh file; append mode refuses anything but a
|
|
53
|
+
* regular, singly linked file (owned by this user when under `root`).
|
|
54
|
+
* Returns the descriptor; the caller closes it.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Fail unless `fd` is the file `file` names now, below `root`, with no link
|
|
58
|
+
* anywhere between them: the directory's realpath is the realpath of `root`
|
|
59
|
+
* plus the same relative path, each component under `root` is a real
|
|
60
|
+
* directory (owned by this user when `owned`), and `lstat(file)` has the
|
|
61
|
+
* descriptor's dev and ino. See the module comment for the race it closes.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertOpenedUnder(fd: number, file: string, root: string, owned?: boolean): void;
|
|
64
|
+
export declare function openNoFollow(file: string, opts?: NoFollowOptions): number;
|
|
65
|
+
/**
|
|
66
|
+
* `{ root: cwd }` when `file` is inside `cwd` (the workspace, where the agent
|
|
67
|
+
* can write), else `{}`: an operator path elsewhere is only opened without
|
|
68
|
+
* following a final link.
|
|
69
|
+
*/
|
|
70
|
+
export declare function workspaceRoot(file: string, cwd?: string): {
|
|
71
|
+
root?: string;
|
|
72
|
+
};
|
|
73
|
+
/** Write (or append) `data` to `file` through {@link openNoFollow}. Throws on any refusal. */
|
|
74
|
+
export declare function writeFileNoFollow(file: string, data: string | Uint8Array, opts?: NoFollowOptions): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the sweny process writes into an agent-writable workspace.
|
|
3
|
+
*
|
|
4
|
+
* An agent can leave a symlink or a hard link where sweny is about to write
|
|
5
|
+
* (`.sweny/runs/<id>/output.md` -> `~/.bashrc`), or swap a directory on the
|
|
6
|
+
* path for a link. These helpers never follow a link: every directory between
|
|
7
|
+
* the trusted root and the file must be a real directory owned by this user,
|
|
8
|
+
* the final component is opened `O_NOFOLLOW`, and the file written must be a
|
|
9
|
+
* regular file with one link (a replaced link or special file is unlinked and
|
|
10
|
+
* created fresh with `O_EXCL`). Data goes through the descriptor that was
|
|
11
|
+
* checked; the mode is set on it.
|
|
12
|
+
*
|
|
13
|
+
* Race with a process that swaps a checked directory for a link (TOCTOU):
|
|
14
|
+
* Node has no `openat(2)`, so the parent chain is checked by pathname and the
|
|
15
|
+
* file opened by pathname. After the open, {@link assertOpenedUnder} re-checks
|
|
16
|
+
* the chain (realpath equals the lexical path, every component a real owned
|
|
17
|
+
* directory) and requires that the pathname now names the very inode the
|
|
18
|
+
* descriptor holds (dev and ino). A swap before the open either leaves a link
|
|
19
|
+
* on the path or moves the opened inode off it; both fail closed before a byte
|
|
20
|
+
* is written. Residual risk: an `O_CREAT` through a swapped directory can leave
|
|
21
|
+
* an empty file outside the workspace before the check fails, and the unlink of
|
|
22
|
+
* a planted link or special file is by pathname (checked just before, not
|
|
23
|
+
* atomic). A regular file is never unlinked or truncated by pathname.
|
|
24
|
+
*
|
|
25
|
+
* Node only.
|
|
26
|
+
*/
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
30
|
+
/** The process uid, or undefined where there is none (Windows). */
|
|
31
|
+
function uid() {
|
|
32
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** True when `child` is `root` or inside it. */
|
|
35
|
+
function within(root, child) {
|
|
36
|
+
const rel = path.relative(root, child);
|
|
37
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
38
|
+
}
|
|
39
|
+
function assertOwnedDir(dir) {
|
|
40
|
+
const st = fs.lstatSync(dir);
|
|
41
|
+
if (st.isSymbolicLink())
|
|
42
|
+
throw new Error(`${dir} is a symlink`);
|
|
43
|
+
if (!st.isDirectory())
|
|
44
|
+
throw new Error(`${dir} is not a directory`);
|
|
45
|
+
const me = uid();
|
|
46
|
+
if (me !== undefined && st.uid !== me)
|
|
47
|
+
throw new Error(`${dir} is not owned by this user`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create `dir` and its missing parents below `root`, checking each component
|
|
51
|
+
* under `root` with lstat: a real directory, owned by this user. `root` itself
|
|
52
|
+
* is trusted. A `dir` outside `root` is created as usual and only its last
|
|
53
|
+
* component is checked.
|
|
54
|
+
*/
|
|
55
|
+
export function ensureDirNoFollow(dir, root, mode = 0o700) {
|
|
56
|
+
const absRoot = path.resolve(root);
|
|
57
|
+
const absDir = path.resolve(dir);
|
|
58
|
+
if (!within(absRoot, absDir)) {
|
|
59
|
+
fs.mkdirSync(absDir, { recursive: true, mode });
|
|
60
|
+
assertOwnedDir(absDir);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let cur = absRoot;
|
|
64
|
+
for (const part of path.relative(absRoot, absDir).split(path.sep).filter(Boolean)) {
|
|
65
|
+
cur = path.join(cur, part);
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(cur, { mode });
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (err.code !== "EEXIST")
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
assertOwnedDir(cur);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Open `file` for writing without following a link. Replace mode unlinks what
|
|
78
|
+
* is there and creates a fresh file; append mode refuses anything but a
|
|
79
|
+
* regular, singly linked file (owned by this user when under `root`).
|
|
80
|
+
* Returns the descriptor; the caller closes it.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* Fail unless `fd` is the file `file` names now, below `root`, with no link
|
|
84
|
+
* anywhere between them: the directory's realpath is the realpath of `root`
|
|
85
|
+
* plus the same relative path, each component under `root` is a real
|
|
86
|
+
* directory (owned by this user when `owned`), and `lstat(file)` has the
|
|
87
|
+
* descriptor's dev and ino. See the module comment for the race it closes.
|
|
88
|
+
*/
|
|
89
|
+
export function assertOpenedUnder(fd, file, root, owned = true) {
|
|
90
|
+
const abs = path.resolve(file);
|
|
91
|
+
const absRoot = path.resolve(root);
|
|
92
|
+
const dir = path.dirname(abs);
|
|
93
|
+
if (!within(absRoot, dir))
|
|
94
|
+
throw new Error(`${abs} is outside ${absRoot}`);
|
|
95
|
+
const rel = path.relative(absRoot, dir);
|
|
96
|
+
const realDir = fs.realpathSync(dir);
|
|
97
|
+
if (path.relative(fs.realpathSync(absRoot), realDir) !== rel)
|
|
98
|
+
throw new Error(`${dir} moved during the open`);
|
|
99
|
+
let cur = absRoot;
|
|
100
|
+
for (const part of rel.split(path.sep).filter(Boolean)) {
|
|
101
|
+
cur = path.join(cur, part);
|
|
102
|
+
if (owned)
|
|
103
|
+
assertOwnedDir(cur);
|
|
104
|
+
else if (!fs.lstatSync(cur).isDirectory())
|
|
105
|
+
throw new Error(`${cur} is not a directory`);
|
|
106
|
+
}
|
|
107
|
+
const named = fs.lstatSync(abs);
|
|
108
|
+
const held = fs.fstatSync(fd);
|
|
109
|
+
if (named.dev !== held.dev || named.ino !== held.ino)
|
|
110
|
+
throw new Error(`${abs} changed during the open`);
|
|
111
|
+
}
|
|
112
|
+
// O_NONBLOCK: a FIFO left in place fails fast (ENXIO) instead of blocking the open.
|
|
113
|
+
const NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
|
|
114
|
+
/** Open errors that mean a link (O_NOFOLLOW), a FIFO with no reader, or a socket at the path. */
|
|
115
|
+
const LINK_OR_SPECIAL = new Set(["ELOOP", "EMLINK", "ENXIO", "EOPNOTSUPP"]);
|
|
116
|
+
/**
|
|
117
|
+
* Replace mode. What is there is opened without following or truncating it: a
|
|
118
|
+
* regular, singly linked file (ours, under a root) is kept, and the caller
|
|
119
|
+
* truncates it through the descriptor once verified. Anything else (a link, a
|
|
120
|
+
* hard link, a FIFO, a socket, another user's file) is unlinked after the
|
|
121
|
+
* parent is re-checked, and the file created fresh with O_EXCL.
|
|
122
|
+
*/
|
|
123
|
+
function openForReplace(abs, root, owned, mode) {
|
|
124
|
+
const { O_WRONLY, O_CREAT, O_EXCL } = fs.constants;
|
|
125
|
+
try {
|
|
126
|
+
const fd = fs.openSync(abs, O_WRONLY | O_CREAT | NOFOLLOW | NONBLOCK, mode);
|
|
127
|
+
const st = fs.fstatSync(fd);
|
|
128
|
+
const me = uid();
|
|
129
|
+
if (st.isFile() && st.nlink === 1 && (!owned || me === undefined || st.uid === me))
|
|
130
|
+
return fd;
|
|
131
|
+
fs.closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (!LINK_OR_SPECIAL.has(err.code ?? ""))
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
if (fs.lstatSync(abs).isDirectory())
|
|
138
|
+
throw new Error(`${abs} is a directory`);
|
|
139
|
+
const dir = path.dirname(abs);
|
|
140
|
+
if (path.relative(fs.realpathSync(root), fs.realpathSync(dir)) !== path.relative(root, dir)) {
|
|
141
|
+
throw new Error(`${dir} moved during the open`);
|
|
142
|
+
}
|
|
143
|
+
fs.unlinkSync(abs);
|
|
144
|
+
return fs.openSync(abs, O_WRONLY | O_CREAT | O_EXCL | NOFOLLOW, mode);
|
|
145
|
+
}
|
|
146
|
+
export function openNoFollow(file, opts = {}) {
|
|
147
|
+
const abs = path.resolve(file);
|
|
148
|
+
const root = path.resolve(opts.root ?? path.dirname(abs));
|
|
149
|
+
if (opts.mkdirs !== false) {
|
|
150
|
+
if (opts.root === undefined)
|
|
151
|
+
fs.mkdirSync(root, { recursive: true, mode: opts.dirMode ?? 0o700 });
|
|
152
|
+
ensureDirNoFollow(path.dirname(abs), root, opts.dirMode ?? 0o700);
|
|
153
|
+
}
|
|
154
|
+
else if (opts.root !== undefined) {
|
|
155
|
+
// Check the existing chain without creating anything.
|
|
156
|
+
let cur = root;
|
|
157
|
+
for (const part of path.relative(root, path.dirname(abs)).split(path.sep).filter(Boolean)) {
|
|
158
|
+
cur = path.join(cur, part);
|
|
159
|
+
assertOwnedDir(cur);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const mode = opts.mode ?? 0o600;
|
|
163
|
+
const { O_WRONLY, O_CREAT, O_APPEND } = fs.constants;
|
|
164
|
+
const owned = opts.root !== undefined;
|
|
165
|
+
const fd = opts.append
|
|
166
|
+
? fs.openSync(abs, O_WRONLY | O_APPEND | O_CREAT | NOFOLLOW | NONBLOCK, mode)
|
|
167
|
+
: openForReplace(abs, root, owned, mode);
|
|
168
|
+
try {
|
|
169
|
+
assertOpenedUnder(fd, abs, root, owned);
|
|
170
|
+
const st = fs.fstatSync(fd);
|
|
171
|
+
if (!st.isFile())
|
|
172
|
+
throw new Error(`${abs} is not a regular file`);
|
|
173
|
+
if (st.nlink !== 1)
|
|
174
|
+
throw new Error(`${abs} has ${st.nlink} links`);
|
|
175
|
+
const me = uid();
|
|
176
|
+
if (me !== undefined && opts.root !== undefined && st.uid !== me) {
|
|
177
|
+
throw new Error(`${abs} is not owned by this user`);
|
|
178
|
+
}
|
|
179
|
+
if (!opts.append)
|
|
180
|
+
fs.ftruncateSync(fd, 0);
|
|
181
|
+
// An operator's existing file (a runner's step summary) keeps its mode.
|
|
182
|
+
if (!opts.append || opts.root !== undefined)
|
|
183
|
+
fs.fchmodSync(fd, mode);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
fs.closeSync(fd);
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
return fd;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* `{ root: cwd }` when `file` is inside `cwd` (the workspace, where the agent
|
|
193
|
+
* can write), else `{}`: an operator path elsewhere is only opened without
|
|
194
|
+
* following a final link.
|
|
195
|
+
*/
|
|
196
|
+
export function workspaceRoot(file, cwd = process.cwd()) {
|
|
197
|
+
const root = path.resolve(cwd);
|
|
198
|
+
return within(root, path.resolve(cwd, file)) ? { root } : {};
|
|
199
|
+
}
|
|
200
|
+
/** Write (or append) `data` to `file` through {@link openNoFollow}. Throws on any refusal. */
|
|
201
|
+
export function writeFileNoFollow(file, data, opts = {}) {
|
|
202
|
+
const fd = openNoFollow(file, opts);
|
|
203
|
+
try {
|
|
204
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
205
|
+
for (let off = 0; off < buf.length;)
|
|
206
|
+
off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
fs.closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
}
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* labels, title prefix, dedupe, node cap, run cap. An optional model screen runs last
|
|
14
14
|
* over the intents that passed and can only veto them.
|
|
15
15
|
*/
|
|
16
|
-
import type { Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool } from "./types.js";
|
|
16
|
+
import type { BranchPusher, Logger, SafeOutputDeclaration, SafeOutputPin, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool } from "./types.js";
|
|
17
17
|
/** Name of the intent-recording tool a node with `outputs` receives. */
|
|
18
18
|
export declare const EMIT_OUTPUT_TOOL = "emit_output";
|
|
19
19
|
/** Longest title the write stage accepts (after the prefix). */
|
|
@@ -95,6 +95,12 @@ export interface ApplySafeOutputsOptions {
|
|
|
95
95
|
actor: ActorInfo;
|
|
96
96
|
/** Preview only: print what would be written, write nothing. */
|
|
97
97
|
staged: boolean;
|
|
98
|
+
/** The run's checkout, for the PR head push (#473). */
|
|
99
|
+
cwd?: string;
|
|
100
|
+
/** The PR head push, bound to `cwd` (#473). */
|
|
101
|
+
pushBranch?: BranchPusher;
|
|
102
|
+
/** The operator's GITHUB_API_URL for the github skill (GHES). */
|
|
103
|
+
githubApiUrl?: string;
|
|
98
104
|
state: WriteStageState;
|
|
99
105
|
logger: Logger;
|
|
100
106
|
/** The run input, for `number: { input }` pins. */
|
package/dist/safe-outputs.js
CHANGED
|
@@ -565,7 +565,13 @@ export async function applySafeOutputs(o) {
|
|
|
565
565
|
continue;
|
|
566
566
|
}
|
|
567
567
|
try {
|
|
568
|
-
const output = await tool.handler(applier.build(write), {
|
|
568
|
+
const output = await tool.handler(applier.build(write), {
|
|
569
|
+
config: o.config,
|
|
570
|
+
logger: o.logger,
|
|
571
|
+
...(o.cwd ? { cwd: o.cwd } : {}),
|
|
572
|
+
...(o.pushBranch ? { pushBranch: o.pushBranch } : {}),
|
|
573
|
+
...(o.githubApiUrl ? { githubApiUrl: o.githubApiUrl } : {}),
|
|
574
|
+
});
|
|
569
575
|
const ref = refOf(output);
|
|
570
576
|
const url = urlOf(output);
|
|
571
577
|
receipts[index] = { ...receipts[index], ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
|
|
@@ -7,14 +7,51 @@
|
|
|
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
|
*/
|
|
54
|
+
import type { BranchPusher } from "../types.js";
|
|
18
55
|
export interface GitRunResult {
|
|
19
56
|
code: number;
|
|
20
57
|
stdout: string;
|
|
@@ -24,6 +61,16 @@ export type GitRunner = (args: string[], opts: {
|
|
|
24
61
|
cwd: string;
|
|
25
62
|
env: NodeJS.ProcessEnv;
|
|
26
63
|
}) => Promise<GitRunResult>;
|
|
64
|
+
/** The trusted entries of a PATH value, in order (see {@link trustedDir}). */
|
|
65
|
+
export declare function trustedPathDirs(pathValue: string | undefined, workspace: string): string[];
|
|
66
|
+
/**
|
|
67
|
+
* The absolute path of the first `git` on `pathValue` (the startup PATH) whose
|
|
68
|
+
* directory, and whose resolved file and its directory, pass
|
|
69
|
+
* {@link trustedDir}. Undefined when there is none: nothing is pushed.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveTrustedGit(pathValue: string | undefined, workspace: string): string | undefined;
|
|
72
|
+
/** A runner that executes the git at `gitPath` (absolute), never a PATH lookup. */
|
|
73
|
+
export declare function gitAt(gitPath: string): GitRunner;
|
|
27
74
|
export interface PushHeadOptions {
|
|
28
75
|
/** `owner/repo` the PR is for. */
|
|
29
76
|
repo: string;
|
|
@@ -31,12 +78,30 @@ export interface PushHeadOptions {
|
|
|
31
78
|
head: string;
|
|
32
79
|
/** The PR's base branch. */
|
|
33
80
|
base: string;
|
|
34
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The repo's default branch, from the GitHub API. Without it nothing is
|
|
83
|
+
* pushed: the checkout's `origin/HEAD` is agent-written and not consulted.
|
|
84
|
+
*/
|
|
85
|
+
defaultBranch?: string;
|
|
86
|
+
/**
|
|
87
|
+
* The head branch's sha on the remote, as the API reported it, or "" when it
|
|
88
|
+
* did not exist (`headPushLease` in github.ts). The push leases on it, so a
|
|
89
|
+
* branch created or moved since the check is not overwritten. Without it
|
|
90
|
+
* nothing is pushed.
|
|
91
|
+
*/
|
|
92
|
+
remoteSha?: string;
|
|
93
|
+
/** The github skill's token. Without it nothing is pushed. */
|
|
35
94
|
token?: string;
|
|
36
|
-
/** The checkout
|
|
37
|
-
cwd
|
|
95
|
+
/** The checkout (the run's `cwd`). */
|
|
96
|
+
cwd: string;
|
|
97
|
+
/**
|
|
98
|
+
* The operator's env: `GITHUB_SERVER_URL`, proxy and CA settings. Default:
|
|
99
|
+
* `process.env`, read through the startup snapshot (startup-env.ts), so a
|
|
100
|
+
* value the workspace `.env` introduced is never used. PATH always comes from
|
|
101
|
+
* the startup snapshot.
|
|
102
|
+
*/
|
|
38
103
|
env?: NodeJS.ProcessEnv;
|
|
39
|
-
/** Test seam. */
|
|
104
|
+
/** Test seam. Default: the git {@link resolveTrustedGit} finds on the startup PATH. */
|
|
40
105
|
git?: GitRunner;
|
|
41
106
|
}
|
|
42
107
|
export interface PushHeadResult {
|
|
@@ -54,9 +119,33 @@ export declare function repoFromRemote(url: string): {
|
|
|
54
119
|
} | undefined;
|
|
55
120
|
/** A branch name sweny will pass to git: plain ref characters, no option or traversal forms. */
|
|
56
121
|
export declare function isSafeBranch(name: string): boolean;
|
|
122
|
+
/** `owner/repo` with plain name characters only. */
|
|
123
|
+
export declare function isSafeRepo(repo: string): boolean;
|
|
57
124
|
/** The command-scope git config that authenticates one push to `server` with `token`. */
|
|
58
125
|
export declare function pushAuthConfig(server: string, token: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
59
126
|
/**
|
|
60
|
-
*
|
|
127
|
+
* Command-scope transport config for the push to `destination`, keyed to that
|
|
128
|
+
* exact URL so it beats repo-local `http.*` and `http.<url>.*` entries: the
|
|
129
|
+
* proxy from the trusted env or none (an empty `http.proxy` disables proxying,
|
|
130
|
+
* env included), TLS verification on, and a CA bundle only from the trusted env.
|
|
131
|
+
*/
|
|
132
|
+
export declare function pushTransportConfig(destination: string, trusted: NodeJS.ProcessEnv): Array<[string, string]>;
|
|
133
|
+
/**
|
|
134
|
+
* The env every sweny-side git call runs with: the trusted PATH, no system or
|
|
135
|
+
* global config, no prompts, no inherited git settings, and no kept value the
|
|
136
|
+
* workspace `.env` introduced.
|
|
137
|
+
*/
|
|
138
|
+
export declare function hardenedGitEnv(env: NodeJS.ProcessEnv, trustedPath?: string): NodeJS.ProcessEnv;
|
|
139
|
+
/**
|
|
140
|
+
* True when a fresh `git init --bare` config holds only repo-format keys
|
|
141
|
+
* (`[core]` format/fs flags, `[extensions]` object format and ref storage).
|
|
142
|
+
*/
|
|
143
|
+
export declare function isPlainRepoConfig(text: string): boolean;
|
|
144
|
+
/** A branch name without a leading `refs/heads/`, so `refs/heads/develop` and `develop` compare equal. */
|
|
145
|
+
export declare function branchName(ref: string): string;
|
|
146
|
+
/**
|
|
147
|
+
* Push the PR's head branch from the sweny process. Never throws.
|
|
61
148
|
*/
|
|
62
149
|
export declare function pushHeadBranch(opts: PushHeadOptions): Promise<PushHeadResult>;
|
|
150
|
+
/** A pusher bound to one run's checkout and env (handed to tools as `ToolContext.pushBranch`). */
|
|
151
|
+
export declare function bindBranchPusher(cwd: string, env?: NodeJS.ProcessEnv): BranchPusher;
|