@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.
@@ -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
+ }
@@ -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. */
@@ -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), { config: o.config, logger: o.logger });
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
- * 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
  */
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
- /** The github skill's token. Without it, git uses whatever credential the checkout has. */
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. Default: `process.cwd()`. */
37
- cwd?: string;
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
- * Push the PR's head branch to `origin` from the sweny process. Never throws.
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;