@sweny-ai/core 0.1.176 → 0.1.178

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.
Files changed (49) hide show
  1. package/dist/agent-env.d.ts +9 -0
  2. package/dist/agent-env.js +4 -0
  3. package/dist/cli/comment-output.js +4 -5
  4. package/dist/cli/config-file.d.ts +37 -2
  5. package/dist/cli/config-file.js +68 -3
  6. package/dist/cli/final-output.d.ts +4 -1
  7. package/dist/cli/final-output.js +6 -8
  8. package/dist/cli/main.d.ts +2 -1
  9. package/dist/cli/main.js +18 -10
  10. package/dist/cli/resume.js +1 -0
  11. package/dist/cli/run-history.d.ts +3 -7
  12. package/dist/cli/run-history.js +5 -12
  13. package/dist/cli/run-output.d.ts +16 -10
  14. package/dist/cli/run-output.js +30 -16
  15. package/dist/cli/ticket.js +3 -2
  16. package/dist/cli/try.js +2 -3
  17. package/dist/decider.d.ts +176 -64
  18. package/dist/decider.js +475 -155
  19. package/dist/executor.d.ts +55 -7
  20. package/dist/executor.js +511 -99
  21. package/dist/harness/acp.js +2 -2
  22. package/dist/harness/claude-code.js +6 -4
  23. package/dist/harness/codex.js +2 -2
  24. package/dist/harness/pi.js +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/journal.d.ts +34 -5
  28. package/dist/journal.js +42 -13
  29. package/dist/safe-file.d.ts +74 -0
  30. package/dist/safe-file.js +211 -0
  31. package/dist/safe-outputs.d.ts +7 -1
  32. package/dist/safe-outputs.js +7 -1
  33. package/dist/schema.d.ts +189 -241
  34. package/dist/schema.js +56 -36
  35. package/dist/skills/git-push.d.ts +99 -10
  36. package/dist/skills/git-push.js +413 -52
  37. package/dist/skills/github.d.ts +19 -15
  38. package/dist/skills/github.js +91 -15
  39. package/dist/startup-env.d.ts +30 -0
  40. package/dist/startup-env.js +48 -0
  41. package/dist/types.d.ts +86 -4
  42. package/dist/types.js +22 -0
  43. package/dist/when.d.ts +8 -1
  44. package/dist/when.js +8 -1
  45. package/dist/workflow-builder.js +3 -1
  46. package/dist/workflows/browser.js +2 -2
  47. package/dist/workflows/implement.yml +37 -5
  48. package/dist/workflows/triage.yml +40 -6
  49. package/package.json +1 -1
@@ -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 } : {}) };