@mastra/factory 0.2.2-alpha.0 → 0.2.2-alpha.2

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 (174) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/auth.d.ts +2 -2
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +401 -314
  5. package/dist/auth.js.map +1 -1
  6. package/dist/capabilities/connection.js +1 -1
  7. package/dist/capabilities/intake.js +1 -1
  8. package/dist/capabilities/version-control.js +1 -1
  9. package/dist/factory.d.ts.map +1 -1
  10. package/dist/factory.js +441 -39455
  11. package/dist/factory.js.map +1 -1
  12. package/dist/index.js +2 -39466
  13. package/dist/integrations/base.js +1 -1
  14. package/dist/integrations/github/config.js +36 -22
  15. package/dist/integrations/github/config.js.map +1 -1
  16. package/dist/integrations/github/factory-session.js +33 -37
  17. package/dist/integrations/github/factory-session.js.map +1 -1
  18. package/dist/integrations/github/integration.js +1037 -3624
  19. package/dist/integrations/github/integration.js.map +1 -1
  20. package/dist/integrations/github/issue-triage.js +71 -71
  21. package/dist/integrations/github/issue-triage.js.map +1 -1
  22. package/dist/integrations/github/pat.js +47 -36
  23. package/dist/integrations/github/pat.js.map +1 -1
  24. package/dist/integrations/github/provenance.js +72 -101
  25. package/dist/integrations/github/provenance.js.map +1 -1
  26. package/dist/integrations/github/routes.d.ts.map +1 -1
  27. package/dist/integrations/github/routes.js +1272 -1833
  28. package/dist/integrations/github/routes.js.map +1 -1
  29. package/dist/integrations/github/rules.js +203 -471
  30. package/dist/integrations/github/rules.js.map +1 -1
  31. package/dist/integrations/github/sandbox.d.ts +2 -0
  32. package/dist/integrations/github/sandbox.d.ts.map +1 -1
  33. package/dist/integrations/github/sandbox.js +401 -326
  34. package/dist/integrations/github/sandbox.js.map +1 -1
  35. package/dist/integrations/github/session-subscriptions.js +165 -223
  36. package/dist/integrations/github/session-subscriptions.js.map +1 -1
  37. package/dist/integrations/github/subscriptions.js +43 -51
  38. package/dist/integrations/github/subscriptions.js.map +1 -1
  39. package/dist/integrations/github/token-refresh.js +15 -18
  40. package/dist/integrations/github/token-refresh.js.map +1 -1
  41. package/dist/integrations/github/webhook.js +359 -343
  42. package/dist/integrations/github/webhook.js.map +1 -1
  43. package/dist/integrations/linear/agent-tools.js +77 -100
  44. package/dist/integrations/linear/agent-tools.js.map +1 -1
  45. package/dist/integrations/linear/integration.js +611 -1309
  46. package/dist/integrations/linear/integration.js.map +1 -1
  47. package/dist/integrations/linear/routes.js +227 -230
  48. package/dist/integrations/linear/routes.js.map +1 -1
  49. package/dist/integrations/linear/rules.js +126 -346
  50. package/dist/integrations/linear/rules.js.map +1 -1
  51. package/dist/integrations/linear/storage.js +1 -1
  52. package/dist/integrations/platform/api-client.js +131 -135
  53. package/dist/integrations/platform/api-client.js.map +1 -1
  54. package/dist/integrations/platform/github/event-worker.js +275 -573
  55. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  56. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  57. package/dist/integrations/platform/github/integration.js +845 -3942
  58. package/dist/integrations/platform/github/integration.js.map +1 -1
  59. package/dist/integrations/platform/linear/integration.js +457 -1309
  60. package/dist/integrations/platform/linear/integration.js.map +1 -1
  61. package/dist/integrations/workos/integration.js +80 -85
  62. package/dist/integrations/workos/integration.js.map +1 -1
  63. package/dist/routes/config.d.ts.map +1 -1
  64. package/dist/routes/config.js +933 -915
  65. package/dist/routes/config.js.map +1 -1
  66. package/dist/routes/custom-provider-source.js +110 -93
  67. package/dist/routes/custom-provider-source.js.map +1 -1
  68. package/dist/routes/fs.js +401 -349
  69. package/dist/routes/fs.js.map +1 -1
  70. package/dist/routes/intake.js +165 -165
  71. package/dist/routes/intake.js.map +1 -1
  72. package/dist/routes/oauth.js +447 -25166
  73. package/dist/routes/oauth.js.map +1 -1
  74. package/dist/routes/projects.js +437 -376
  75. package/dist/routes/projects.js.map +1 -1
  76. package/dist/routes/provider-credentials.js +76 -58
  77. package/dist/routes/provider-credentials.js.map +1 -1
  78. package/dist/routes/route.js +12 -8
  79. package/dist/routes/route.js.map +1 -1
  80. package/dist/routes/skills.js +160 -173
  81. package/dist/routes/skills.js.map +1 -1
  82. package/dist/routes/surface.js +250 -28618
  83. package/dist/routes/surface.js.map +1 -1
  84. package/dist/routes/tenant-credentials.js +141 -166
  85. package/dist/routes/tenant-credentials.js.map +1 -1
  86. package/dist/routes/work-items.js +654 -862
  87. package/dist/routes/work-items.js.map +1 -1
  88. package/dist/rules/binding-context.js +23 -35
  89. package/dist/rules/binding-context.js.map +1 -1
  90. package/dist/rules/defaults.js +200 -309
  91. package/dist/rules/defaults.js.map +1 -1
  92. package/dist/rules/dispatcher.d.ts.map +1 -1
  93. package/dist/rules/dispatcher.js +438 -677
  94. package/dist/rules/dispatcher.js.map +1 -1
  95. package/dist/rules/index.js +5 -599
  96. package/dist/rules/processor.js +344 -596
  97. package/dist/rules/processor.js.map +1 -1
  98. package/dist/rules/resolve.js +21 -18
  99. package/dist/rules/resolve.js.map +1 -1
  100. package/dist/rules/start-coordinator.js +168 -157
  101. package/dist/rules/start-coordinator.js.map +1 -1
  102. package/dist/rules/tools.js +50 -85
  103. package/dist/rules/tools.js.map +1 -1
  104. package/dist/rules/transition-service.js +183 -454
  105. package/dist/rules/transition-service.js.map +1 -1
  106. package/dist/rules/types.js +33 -29
  107. package/dist/rules/types.js.map +1 -1
  108. package/dist/rules/validation.js +266 -281
  109. package/dist/rules/validation.js.map +1 -1
  110. package/dist/sandbox/fleet.js +272 -250
  111. package/dist/sandbox/fleet.js.map +1 -1
  112. package/dist/sandbox/reattach.js +13 -6
  113. package/dist/sandbox/reattach.js.map +1 -1
  114. package/dist/server-error.js +12 -16
  115. package/dist/server-error.js.map +1 -1
  116. package/dist/skills/service.js +30 -35
  117. package/dist/skills/service.js.map +1 -1
  118. package/dist/spa-static.d.ts.map +1 -1
  119. package/dist/spa-static.js +91 -64
  120. package/dist/spa-static.js.map +1 -1
  121. package/dist/state-signing.js +73 -44
  122. package/dist/state-signing.js.map +1 -1
  123. package/dist/storage/domains/audit/agent-audit.js +60 -59
  124. package/dist/storage/domains/audit/agent-audit.js.map +1 -1
  125. package/dist/storage/domains/audit/base.js +169 -112
  126. package/dist/storage/domains/audit/base.js.map +1 -1
  127. package/dist/storage/domains/audit/domain.js +155 -153
  128. package/dist/storage/domains/audit/domain.js.map +1 -1
  129. package/dist/storage/domains/credentials/base.js +259 -243
  130. package/dist/storage/domains/credentials/base.js.map +1 -1
  131. package/dist/storage/domains/custom-providers/base.js +138 -143
  132. package/dist/storage/domains/custom-providers/base.js.map +1 -1
  133. package/dist/storage/domains/intake/base.js +75 -57
  134. package/dist/storage/domains/intake/base.js.map +1 -1
  135. package/dist/storage/domains/integrations/base.js +310 -245
  136. package/dist/storage/domains/integrations/base.js.map +1 -1
  137. package/dist/storage/domains/memory-settings/base.js +117 -102
  138. package/dist/storage/domains/memory-settings/base.js.map +1 -1
  139. package/dist/storage/domains/model-packs/base.js +89 -88
  140. package/dist/storage/domains/model-packs/base.js.map +1 -1
  141. package/dist/storage/domains/projects/base.js +97 -92
  142. package/dist/storage/domains/projects/base.js.map +1 -1
  143. package/dist/storage/domains/queue-health/base.js +111 -76
  144. package/dist/storage/domains/queue-health/base.js.map +1 -1
  145. package/dist/storage/domains/source-control/base.d.ts +9 -0
  146. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  147. package/dist/storage/domains/source-control/base.js +777 -702
  148. package/dist/storage/domains/source-control/base.js.map +1 -1
  149. package/dist/storage/domains/source-control/inmemory.d.ts +4 -0
  150. package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
  151. package/dist/storage/domains/source-control/inmemory.js +322 -344
  152. package/dist/storage/domains/source-control/inmemory.js.map +1 -1
  153. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  154. package/dist/storage/domains/work-items/base.js +1266 -1132
  155. package/dist/storage/domains/work-items/base.js.map +1 -1
  156. package/dist/storage/domains/work-items/metrics.js +202 -184
  157. package/dist/storage/domains/work-items/metrics.js.map +1 -1
  158. package/dist/timing.d.ts +15 -0
  159. package/dist/timing.d.ts.map +1 -0
  160. package/dist/timing.js +34 -0
  161. package/dist/timing.js.map +1 -0
  162. package/dist/workspace.d.ts +2 -3
  163. package/dist/workspace.d.ts.map +1 -1
  164. package/dist/workspace.js +236 -438
  165. package/dist/workspace.js.map +1 -1
  166. package/factory-skills/factory-review/SKILL.md +9 -2
  167. package/package.json +9 -9
  168. package/dist/capabilities/connection.js.map +0 -1
  169. package/dist/capabilities/intake.js.map +0 -1
  170. package/dist/capabilities/version-control.js.map +0 -1
  171. package/dist/index.js.map +0 -1
  172. package/dist/integrations/base.js.map +0 -1
  173. package/dist/integrations/linear/storage.js.map +0 -1
  174. package/dist/rules/index.js.map +0 -1
@@ -1,381 +1,456 @@
1
- // src/integrations/github/sandbox.ts
1
+ import { reportProgress } from "../../sandbox/fleet.js";
2
2
  import { createHash } from "crypto";
3
-
4
- // src/sandbox/fleet.ts
5
- import path from "path";
6
- function reportProgress(onProgress, event) {
7
- if (!onProgress) return;
8
- try {
9
- onProgress(event);
10
- } catch {
11
- }
12
- }
13
-
14
- // src/integrations/github/sandbox.ts
3
+ //#region src/integrations/github/sandbox.ts
4
+ /**
5
+ * Repo materialization for GitHub-backed repositories.
6
+ *
7
+ * A GitHub repo is never cloned onto the server host. Instead each project gets
8
+ * its own isolated sandbox (provisioned by the fleet in `../sandbox/fleet`) and
9
+ * the repo is cloned *inside* that sandbox. The agent's file tools and command
10
+ * tools then operate entirely against the remote checkout.
11
+ *
12
+ * - `ensureProjectSandbox(row)` / `teardownProjectSandbox(row)` bind the fleet's
13
+ * provision/reattach/teardown lifecycle to the per-(project,user) sandbox row.
14
+ * - `materializeRepo(row, token)` runs `git clone` (first open) or `git pull`
15
+ * (re-open) inside the sandbox, using a short-lived installation token that is
16
+ * scrubbed from the git remote afterwards so it never persists in the VM.
17
+ *
18
+ * This module owns everything git/GitHub: clone/pull, commit/push, worktrees,
19
+ * and `gh pr create`. Sandbox provisioning, budgets, and workdir layout live in
20
+ * the fleet module.
21
+ */
22
+ /** Adapt a per-(project,user) sandbox binding row to the fleet's persistence seam. */
15
23
  function bindingStore(row, storage) {
16
- return {
17
- sandboxId: row.sandboxId,
18
- setSandboxId: (id) => id === null ? storage.clearBinding({ id: row.id }) : storage.setSandboxId({ id: row.id, sandboxId: id }),
19
- clear: () => storage.clearBinding({ id: row.id })
20
- };
24
+ return {
25
+ sandboxId: row.sandboxId,
26
+ setSandboxId: (id) => id === null ? storage.clearBinding({ id: row.id }) : storage.setSandboxId({
27
+ id: row.id,
28
+ sandboxId: id
29
+ }),
30
+ clear: () => storage.clearBinding({ id: row.id })
31
+ };
21
32
  }
33
+ /**
34
+ * Provision a new sandbox (persisting its provider id on first open) or
35
+ * reattach to the stored one. Returns a started, live sandbox.
36
+ */
22
37
  async function ensureProjectSandbox(options) {
23
- const { fleet, row, storage, token, onProgress } = options;
24
- return fleet.ensureSandbox(bindingStore(row, storage), { GH_TOKEN: token }, onProgress);
38
+ const { fleet, row, storage, token, onProgress } = options;
39
+ return fleet.ensureSandbox(bindingStore(row, storage), { GH_TOKEN: token }, onProgress);
25
40
  }
41
+ /**
42
+ * Tear down a user's sandbox for a project: stop the live VM (best-effort) and
43
+ * clear the persisted `sandboxId`/`materializedAt` on the per-(project,user)
44
+ * binding row so the next open re-provisions cleanly.
45
+ *
46
+ * @param row the per-(project,user) sandbox binding to tear down
47
+ * @param sandbox an already-reattached live sandbox to stop, when available
48
+ */
26
49
  async function teardownProjectSandbox(options) {
27
- const { fleet, row, storage, sandbox } = options;
28
- return fleet.teardownSandbox(bindingStore(row, storage), sandbox);
50
+ const { fleet, row, storage, sandbox } = options;
51
+ return fleet.teardownSandbox(bindingStore(row, storage), sandbox);
29
52
  }
53
+ /**
54
+ * Single-quote a string for safe POSIX shell interpolation. Wraps the value in
55
+ * single quotes and escapes any embedded single quote using the canonical
56
+ * close-quote / escaped-quote / reopen-quote sequence (`'\''`). This is the
57
+ * standard POSIX-safe construction and prevents the quoted string from being
58
+ * terminated early.
59
+ */
30
60
  function shellQuote(value) {
31
- return `'` + value.split(`'`).join(`'\\''`) + `'`;
61
+ return `'` + value.split(`'`).join(`'\\''`) + `'`;
32
62
  }
33
- async function sh(sandbox, script) {
34
- return sandbox.executeCommand("sh", ["-c", script]);
63
+ /**
64
+ * Default hang guard for sandbox shell commands. Generous by design — large
65
+ * clones and dependency installs legitimately take minutes; the guard exists
66
+ * so a wedged sandbox surfaces a failure instead of hanging the request that
67
+ * triggered materialization forever.
68
+ */
69
+ const DEFAULT_COMMAND_TIMEOUT_MS = 15 * 6e4;
70
+ /** Branch checkout only fetches one ref — a much tighter budget applies. */
71
+ const CHECKOUT_COMMAND_TIMEOUT_MS = 5 * 6e4;
72
+ /** Run a shell script in the sandbox via `sh -c`, bounded by a hang guard. */
73
+ async function sh(sandbox, script, options = {}) {
74
+ const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
75
+ let timer;
76
+ const hangGuard = new Promise((_, reject) => {
77
+ timer = setTimeout(() => {
78
+ const phase = options.phase ? ` during ${options.phase}` : "";
79
+ reject(/* @__PURE__ */ new Error(`Sandbox command timed out after ${Math.round(timeoutMs / 1e3)}s${phase}.`));
80
+ }, timeoutMs);
81
+ timer.unref?.();
82
+ });
83
+ try {
84
+ return await Promise.race([sandbox.executeCommand("sh", ["-c", script], { timeout: timeoutMs }), hangGuard]);
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
35
88
  }
89
+ /** Error raised when the sandbox cannot materialize the repo (actionable). */
36
90
  var MaterializeError = class extends Error {
37
- constructor(message, code) {
38
- super(message);
39
- this.code = code;
40
- this.name = "MaterializeError";
41
- }
42
- code;
91
+ code;
92
+ constructor(message, code) {
93
+ super(message);
94
+ this.code = code;
95
+ this.name = "MaterializeError";
96
+ }
43
97
  };
98
+ /**
99
+ * Build the token-auth clone/pull URL for a repo. The token lives only inside
100
+ * this URL and is scrubbed from the remote after the operation.
101
+ */
44
102
  function tokenUrl(repoFullName, token) {
45
- return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
103
+ return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
46
104
  }
47
105
  function cleanUrl(repoFullName) {
48
- return `https://github.com/${repoFullName}.git`;
106
+ return `https://github.com/${repoFullName}.git`;
49
107
  }
108
+ /**
109
+ * Materialize the repo inside the user's sandbox. Clones on first open, pulls on
110
+ * re-open. Always scrubs the install token from the remote afterwards and sets
111
+ * `materialized_at` on the per-user sandbox binding row.
112
+ *
113
+ */
50
114
  async function materializeRepo(options) {
51
- const { row: sandboxRow, repoInfo, sandbox, token, storage, onProgress } = options;
52
- const workdir = sandboxRow.sandboxWorkdir;
53
- const repo = repoInfo.repoFullName;
54
- if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
55
- throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, "clone-failed");
56
- }
57
- if (!/^[A-Za-z0-9_./-]+$/.test(repoInfo.defaultBranch)) {
58
- throw new MaterializeError(
59
- `Refusing to materialize: invalid default branch '${repoInfo.defaultBranch}'.`,
60
- "clone-failed"
61
- );
62
- }
63
- const gitVersion = await sh(sandbox, "git --version");
64
- if (gitVersion.exitCode !== 0) {
65
- throw new MaterializeError(
66
- "git is not installed in the sandbox. The sandbox template must include git.",
67
- "git-missing"
68
- );
69
- }
70
- const authUrl = tokenUrl(repo, token);
71
- const alreadyMaterialized = Boolean(sandboxRow.materializedAt) || await hasExistingCheckout(sandbox, workdir, repo);
72
- try {
73
- if (!alreadyMaterialized) {
74
- reportProgress(onProgress, {
75
- phase: "cloning",
76
- message: `Cloning ${repo} (first open can take a minute)\u2026`
77
- });
78
- const clone = await sh(
79
- sandbox,
80
- `git clone --depth=1 --single-branch --branch ${shellQuote(repoInfo.defaultBranch)} ${shellQuote(authUrl)} ${shellQuote(workdir)}`
81
- );
82
- if (clone.exitCode !== 0) {
83
- throw classifyGitFailure(clone, "clone-failed");
84
- }
85
- } else {
86
- reportProgress(onProgress, { phase: "pulling", message: `Updating ${repo} to the latest changes\u2026` });
87
- const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`);
88
- if (setUrl.exitCode !== 0) {
89
- throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr}`, "pull-failed");
90
- }
91
- const pull = await sh(sandbox, `git -C ${shellQuote(workdir)} pull --ff-only`);
92
- if (pull.exitCode !== 0) {
93
- throw classifyGitFailure(pull, "pull-failed");
94
- }
95
- }
96
- } finally {
97
- await scrubRemote(sandbox, workdir, repo, alreadyMaterialized);
98
- }
99
- reportProgress(onProgress, { phase: "finalizing", message: "Finalizing workspace\u2026" });
100
- await storage.markMaterialized({ id: sandboxRow.id });
115
+ const { row: sandboxRow, repoInfo, sandbox, token, storage, onProgress } = options;
116
+ const workdir = sandboxRow.sandboxWorkdir;
117
+ const repo = repoInfo.repoFullName;
118
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, "clone-failed");
119
+ if (!/^[A-Za-z0-9_./-]+$/.test(repoInfo.defaultBranch)) throw new MaterializeError(`Refusing to materialize: invalid default branch '${repoInfo.defaultBranch}'.`, "clone-failed");
120
+ if ((await sh(sandbox, "git --version")).exitCode !== 0) throw new MaterializeError("git is not installed in the sandbox. The sandbox template must include git.", "git-missing");
121
+ const authUrl = tokenUrl(repo, token);
122
+ const alreadyMaterialized = await hasExistingCheckout(sandbox, workdir, repo);
123
+ let succeeded = false;
124
+ try {
125
+ if (!alreadyMaterialized) {
126
+ reportProgress(onProgress, {
127
+ phase: "cloning",
128
+ message: `Cloning ${repo} (first open can take a minute)…`
129
+ });
130
+ const clone = await sh(sandbox, `git clone --depth=1 --single-branch --branch ${shellQuote(repoInfo.defaultBranch)} ${shellQuote(authUrl)} ${shellQuote(workdir)}`, { phase: "repository clone" });
131
+ if (clone.exitCode !== 0) throw classifyGitFailure(clone, "clone-failed");
132
+ } else {
133
+ reportProgress(onProgress, {
134
+ phase: "pulling",
135
+ message: `Updating ${repo} to the latest changes…`
136
+ });
137
+ const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`);
138
+ if (setUrl.exitCode !== 0) throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr}`, "pull-failed");
139
+ const pull = await sh(sandbox, `git -C ${shellQuote(workdir)} pull --ff-only`, { phase: "repository pull" });
140
+ if (pull.exitCode !== 0) throw classifyGitFailure(pull, "pull-failed");
141
+ }
142
+ succeeded = true;
143
+ } finally {
144
+ await scrubRemote(sandbox, workdir, repo, succeeded);
145
+ }
146
+ reportProgress(onProgress, {
147
+ phase: "finalizing",
148
+ message: "Finalizing workspace…"
149
+ });
150
+ await storage.markMaterialized({ id: sandboxRow.id });
101
151
  }
102
- async function checkoutSessionBranch(sandbox, workdir, {
103
- branch,
104
- baseBranch,
105
- token,
106
- repoFullName
107
- }) {
108
- if (!isValidGitRef(branch) || !isValidGitRef(baseBranch)) {
109
- throw new MaterializeError("Refusing to create a session from an invalid branch name.", "clone-failed");
110
- }
111
- const current = await sh(sandbox, `git -C ${shellQuote(workdir)} branch --show-current`);
112
- if (current.exitCode === 0 && current.stdout.trim() === branch) return;
113
- const local = await sh(
114
- sandbox,
115
- `git -C ${shellQuote(workdir)} show-ref --verify --quiet refs/heads/${shellQuote(branch)}`
116
- );
117
- if (local.exitCode === 0) {
118
- const checkout = await sh(sandbox, `git -C ${shellQuote(workdir)} checkout ${shellQuote(branch)}`);
119
- if (checkout.exitCode !== 0) throw classifyGitFailure(checkout, "clone-failed");
120
- return;
121
- }
122
- const authUrl = tokenUrl(repoFullName, token);
123
- try {
124
- const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`);
125
- if (setUrl.exitCode !== 0) throw classifyGitFailure(setUrl, "pull-failed");
126
- const fetch = await sh(
127
- sandbox,
128
- `git -C ${shellQuote(workdir)} fetch origin ${shellQuote(baseBranch)} && git -C ${shellQuote(workdir)} checkout -b ${shellQuote(branch)} FETCH_HEAD`
129
- );
130
- if (fetch.exitCode !== 0) throw classifyGitFailure(fetch, "clone-failed");
131
- } finally {
132
- await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`);
133
- }
152
+ /** Check out a session's branch inside its isolated repository clone. */
153
+ async function checkoutSessionBranch(sandbox, workdir, { branch, baseBranch, token, repoFullName }) {
154
+ if (!isValidGitRef(branch) || !isValidGitRef(baseBranch)) throw new MaterializeError("Refusing to create a session from an invalid branch name.", "clone-failed");
155
+ const current = await sh(sandbox, `git -C ${shellQuote(workdir)} branch --show-current`);
156
+ if (current.exitCode === 0 && current.stdout.trim() === branch) return;
157
+ if ((await sh(sandbox, `git -C ${shellQuote(workdir)} show-ref --verify --quiet refs/heads/${shellQuote(branch)}`)).exitCode === 0) {
158
+ const checkout = await sh(sandbox, `git -C ${shellQuote(workdir)} checkout ${shellQuote(branch)}`);
159
+ if (checkout.exitCode !== 0) throw classifyGitFailure(checkout, "clone-failed");
160
+ return;
161
+ }
162
+ const authUrl = tokenUrl(repoFullName, token);
163
+ try {
164
+ const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`);
165
+ if (setUrl.exitCode !== 0) throw classifyGitFailure(setUrl, "pull-failed");
166
+ const fetch = await sh(sandbox, `git -C ${shellQuote(workdir)} fetch origin ${shellQuote(baseBranch)} && git -C ${shellQuote(workdir)} checkout -b ${shellQuote(branch)} FETCH_HEAD`, {
167
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
168
+ phase: "branch checkout"
169
+ });
170
+ if (fetch.exitCode !== 0) throw classifyGitFailure(fetch, "clone-failed");
171
+ } finally {
172
+ await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`);
173
+ }
134
174
  }
175
+ /**
176
+ * True when the workdir already holds a git checkout whose `origin` points at
177
+ * this exact repo. Matches both the clean and token-auth URL forms; any other
178
+ * remote (or no git dir at all) falls back to the clone path.
179
+ */
135
180
  async function hasExistingCheckout(sandbox, workdir, repoFullName) {
136
- const result = await sh(sandbox, `git -C ${shellQuote(workdir)} remote get-url origin`);
137
- if (result.exitCode !== 0) return false;
138
- const url = result.stdout.trim().toLowerCase();
139
- const suffix = `github.com/${repoFullName.toLowerCase()}`;
140
- return url.endsWith(`${suffix}.git`) || url.endsWith(suffix);
181
+ const result = await sh(sandbox, `git -C ${shellQuote(workdir)} remote get-url origin`);
182
+ if (result.exitCode !== 0) return false;
183
+ const url = result.stdout.trim().toLowerCase();
184
+ const suffix = `github.com/${repoFullName.toLowerCase()}`;
185
+ return url.endsWith(`${suffix}.git`) || url.endsWith(suffix);
141
186
  }
187
+ /**
188
+ * Reset the git remote back to the tokenless URL. On a successful clone/pull the
189
+ * workdir always has a `.git`, so a non-zero exit code here means the token may
190
+ * still be persisted — surface it. On the failure path the workdir may not exist
191
+ * (e.g. a failed clone), so a non-zero exit is tolerated — and never masks the
192
+ * primary failure being thrown through the `finally`.
193
+ */
142
194
  async function scrubRemote(sandbox, workdir, repoFullName, expectGitDir) {
143
- const result = await sh(
144
- sandbox,
145
- `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`
146
- );
147
- if (result.exitCode !== 0 && expectGitDir) {
148
- throw new MaterializeError(
149
- `Failed to scrub installation token from git remote: ${result.stderr.trim() || result.stdout.trim()}`,
150
- "pull-failed"
151
- );
152
- }
195
+ const result = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`);
196
+ if (result.exitCode !== 0 && expectGitDir) throw new MaterializeError(`Failed to scrub installation token from git remote: ${result.stderr.trim() || result.stdout.trim()}`, "pull-failed");
153
197
  }
198
+ /**
199
+ * Turn a failed git command into an actionable error, detecting the common
200
+ * "cannot reach github.com" egress failure.
201
+ */
154
202
  function classifyGitFailure(result, fallback) {
155
- const stderr = result.stderr || "";
156
- if (/could not resolve host|failed to connect|network is unreachable|Connection timed out/i.test(stderr)) {
157
- return new MaterializeError(
158
- "The sandbox could not reach github.com. The sandbox network must allow outbound egress to github.com.",
159
- "egress-blocked"
160
- );
161
- }
162
- const verb = fallback === "clone-failed" ? "clone" : fallback === "pull-failed" ? "pull" : "push";
163
- return new MaterializeError(`git ${verb} failed: ${stderr}`, fallback);
203
+ const stderr = result.stderr || "";
204
+ if (/could not resolve host|failed to connect|network is unreachable|Connection timed out/i.test(stderr)) return new MaterializeError("The sandbox could not reach github.com. The sandbox network must allow outbound egress to github.com.", "egress-blocked");
205
+ return new MaterializeError(`git ${fallback === "clone-failed" ? "clone" : fallback === "pull-failed" ? "pull" : "push"} failed: ${stderr}`, fallback);
164
206
  }
207
+ /**
208
+ * Validate a git ref (branch) name. Server-side defense-in-depth: only allow a
209
+ * conservative character set so a branch can never be built into a shell
210
+ * command in a way that escapes quoting. Mirrors the route-layer check.
211
+ */
165
212
  function isValidGitRef(value) {
166
- return typeof value === "string" && value.length > 0 && value.length <= 255 && // Reject leading-dash refs (e.g. `--mirror`) so the value can never be
167
- // parsed as a git option when interpolated into a command.
168
- !value.startsWith("-") && /^[A-Za-z0-9_./-]+$/.test(value);
213
+ return typeof value === "string" && value.length > 0 && value.length <= 255 && !value.startsWith("-") && /^[A-Za-z0-9_./-]+$/.test(value);
169
214
  }
215
+ /**
216
+ * Resolve a concrete `{ name, email }` for git authorship from a possibly-sparse
217
+ * identity. Falls back to a GitHub-style noreply identity so commits are never
218
+ * authored with an empty or host-derived identity.
219
+ */
170
220
  function resolveGitIdentity(identity) {
171
- const login = (identity.login || "").trim();
172
- const name = (identity.name || "").trim() || login || "Mastra Code";
173
- const email = (identity.email || "").trim() || (login ? `${login}@users.noreply.github.com` : "mastra-code@users.noreply.github.com");
174
- return { name, email };
221
+ const login = (identity.login || "").trim();
222
+ return {
223
+ name: (identity.name || "").trim() || login || "Mastra Code",
224
+ email: (identity.email || "").trim() || (login ? `${login}@users.noreply.github.com` : "mastra-code@users.noreply.github.com")
225
+ };
175
226
  }
227
+ /**
228
+ * Configure `user.name` / `user.email` for the given repo working tree inside
229
+ * the sandbox so commits are authored correctly. Values are shell-quoted.
230
+ */
176
231
  async function configureGitIdentity(sandbox, workdir, identity) {
177
- const { name, email } = resolveGitIdentity(identity);
178
- const setName = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.name ${shellQuote(name)}`);
179
- if (setName.exitCode !== 0) {
180
- throw new MaterializeError(`Failed to set git user.name: ${setName.stderr.trim()}`, "commit-failed");
181
- }
182
- const setEmail = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.email ${shellQuote(email)}`);
183
- if (setEmail.exitCode !== 0) {
184
- throw new MaterializeError(`Failed to set git user.email: ${setEmail.stderr.trim()}`, "commit-failed");
185
- }
232
+ const { name, email } = resolveGitIdentity(identity);
233
+ const setName = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.name ${shellQuote(name)}`);
234
+ if (setName.exitCode !== 0) throw new MaterializeError(`Failed to set git user.name: ${setName.stderr.trim()}`, "commit-failed");
235
+ const setEmail = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.email ${shellQuote(email)}`);
236
+ if (setEmail.exitCode !== 0) throw new MaterializeError(`Failed to set git user.email: ${setEmail.stderr.trim()}`, "commit-failed");
186
237
  }
238
+ /**
239
+ * Temporarily rewrite `origin` to a tokenized URL, run `fn` (e.g. a push), and
240
+ * **always** scrub the remote back to the tokenless URL in a `finally`. The
241
+ * token therefore only ever lives in the remote URL for the duration of the
242
+ * operation and is never left in the VM's git config.
243
+ *
244
+ * On the success path the scrub must succeed (a leaked token is a hard error);
245
+ * if it fails we surface it. On the failure path the scrub is best-effort but
246
+ * still attempted, and the original operation error is rethrown.
247
+ */
187
248
  async function withInstallToken(sandbox, workdir, repoFullName, token, fn) {
188
- if (!/^[\w.-]+\/[\w.-]+$/.test(repoFullName)) {
189
- throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
190
- }
191
- const setUrl = await sh(
192
- sandbox,
193
- `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(tokenUrl(repoFullName, token))}`
194
- );
195
- if (setUrl.exitCode !== 0) {
196
- await scrubRemote(sandbox, workdir, repoFullName, false);
197
- throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr.trim()}`, "push-failed");
198
- }
199
- try {
200
- return await fn();
201
- } finally {
202
- await scrubRemote(sandbox, workdir, repoFullName, true);
203
- }
249
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repoFullName)) throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
250
+ const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(tokenUrl(repoFullName, token))}`);
251
+ if (setUrl.exitCode !== 0) {
252
+ await scrubRemote(sandbox, workdir, repoFullName, false);
253
+ throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr.trim()}`, "push-failed");
254
+ }
255
+ try {
256
+ return await fn();
257
+ } finally {
258
+ await scrubRemote(sandbox, workdir, repoFullName, true);
259
+ }
204
260
  }
261
+ /**
262
+ * Push a branch back to GitHub from inside the sandbox using a short-lived
263
+ * installation token. The branch is ref-validated, the token is injected only
264
+ * into the remote URL via `withInstallToken`, and egress failures are
265
+ * classified into actionable errors.
266
+ */
205
267
  async function pushBranch(sandbox, workdir, branch, token, repoFullName) {
206
- if (!isValidGitRef(branch)) {
207
- throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, "push-failed");
208
- }
209
- await withInstallToken(sandbox, workdir, repoFullName, token, async () => {
210
- const push = await sh(sandbox, `git -C ${shellQuote(workdir)} push -u origin ${shellQuote(branch)}`);
211
- if (push.exitCode !== 0) {
212
- throw classifyGitFailure(push, "push-failed");
213
- }
214
- });
268
+ if (!isValidGitRef(branch)) throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, "push-failed");
269
+ await withInstallToken(sandbox, workdir, repoFullName, token, async () => {
270
+ const push = await sh(sandbox, `git -C ${shellQuote(workdir)} push -u origin ${shellQuote(branch)}`);
271
+ if (push.exitCode !== 0) throw classifyGitFailure(push, "push-failed");
272
+ });
215
273
  }
274
+ /**
275
+ * Stage every change in the working tree and create a commit inside the
276
+ * sandbox. The git identity is configured first so authorship is correct. When
277
+ * there is nothing to commit this is a no-op (`committed: false`) rather than an
278
+ * error, so callers can safely commit-then-push without first diffing.
279
+ *
280
+ * @param sandbox the live sandbox containing the checkout
281
+ * @param workdir the worktree (or repo) path to commit in
282
+ * @param message the commit message (quoted; arbitrary text is safe)
283
+ * @param identity authorship identity for the commit
284
+ */
216
285
  async function commitAll(sandbox, workdir, message, identity) {
217
- await configureGitIdentity(sandbox, workdir, identity);
218
- const add = await sh(sandbox, `git -C ${shellQuote(workdir)} add -A`);
219
- if (add.exitCode !== 0) {
220
- throw new MaterializeError(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`, "commit-failed");
221
- }
222
- const staged = await sh(sandbox, `git -C ${shellQuote(workdir)} diff --cached --quiet`);
223
- if (staged.exitCode === 0) {
224
- return { committed: false };
225
- }
226
- const commit = await sh(sandbox, `git -C ${shellQuote(workdir)} commit -m ${shellQuote(message)}`);
227
- if (commit.exitCode !== 0) {
228
- throw new MaterializeError(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`, "commit-failed");
229
- }
230
- return { committed: true };
286
+ await configureGitIdentity(sandbox, workdir, identity);
287
+ const add = await sh(sandbox, `git -C ${shellQuote(workdir)} add -A`);
288
+ if (add.exitCode !== 0) throw new MaterializeError(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`, "commit-failed");
289
+ if ((await sh(sandbox, `git -C ${shellQuote(workdir)} diff --cached --quiet`)).exitCode === 0) return { committed: false };
290
+ const commit = await sh(sandbox, `git -C ${shellQuote(workdir)} commit -m ${shellQuote(message)}`);
291
+ if (commit.exitCode !== 0) throw new MaterializeError(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`, "commit-failed");
292
+ return { committed: true };
231
293
  }
294
+ /** Error raised when a worktree cannot be created/reused inside the sandbox. */
232
295
  var WorktreeError = class extends Error {
233
- constructor(message, code) {
234
- super(message);
235
- this.code = code;
236
- this.name = "WorktreeError";
237
- }
238
- code;
296
+ code;
297
+ constructor(message, code) {
298
+ super(message);
299
+ this.code = code;
300
+ this.name = "WorktreeError";
301
+ }
239
302
  };
303
+ /**
304
+ * Reduce a (already ref-validated) branch name to a filesystem-safe directory
305
+ * segment for the worktree path: slashes/dots/unsafe chars collapsed to `-`.
306
+ * This only affects the *directory name*, never the git branch itself.
307
+ *
308
+ * Sanitization is lossy (e.g. `feat/a` and `feat-a` both reduce to `feat-a`),
309
+ * so an 8-char hash of the original branch is appended whenever the sanitized
310
+ * form differs from the input. That keeps clean names (`main`) readable while
311
+ * guaranteeing distinct branches never share a worktree directory.
312
+ */
240
313
  function safeBranchDir(branch) {
241
- const sanitized = branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\/+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 100) || "work";
242
- if (sanitized === branch) return sanitized;
243
- const hash = createHash("sha256").update(branch).digest("hex").slice(0, 8);
244
- return `${sanitized}-${hash}`;
314
+ const sanitized = branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/\/+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 100) || "work";
315
+ if (sanitized === branch) return sanitized;
316
+ return `${sanitized}-${createHash("sha256").update(branch).digest("hex").slice(0, 8)}`;
245
317
  }
318
+ /**
319
+ * Compute the absolute worktree path for a branch, server-side only. Worktrees
320
+ * live alongside the repo checkout under a sibling `worktrees/` directory so the
321
+ * repo's `.git` is shared. Never derived from client-supplied paths.
322
+ */
246
323
  function computeWorktreePath(repoWorkdir, branch) {
247
- const parent = repoWorkdir.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "";
248
- return `${parent}/worktrees/${safeBranchDir(branch)}`;
324
+ return `${repoWorkdir.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || ""}/worktrees/${safeBranchDir(branch)}`;
249
325
  }
250
- async function ensureWorktree(sandbox, repoWorkdir, {
251
- branch,
252
- baseBranch,
253
- token,
254
- repoFullName
255
- }) {
256
- if (!isValidGitRef(branch)) {
257
- throw new WorktreeError(`Invalid branch name '${branch}'.`, "invalid-branch");
258
- }
259
- if (!isValidGitRef(baseBranch)) {
260
- throw new WorktreeError(`Invalid base branch name '${baseBranch}'.`, "invalid-branch");
261
- }
262
- const worktreePath = computeWorktreePath(repoWorkdir, branch);
263
- const exists = await sh(sandbox, `test -e ${shellQuote(`${worktreePath}/.git`)}`);
264
- if (exists.exitCode === 0) {
265
- return { worktreePath, branch, baseBranch, reused: true };
266
- }
267
- const baseRef = `origin/${baseBranch}`;
268
- await withInstallToken(sandbox, repoWorkdir, repoFullName, token, async () => {
269
- const fetch = await sh(
270
- sandbox,
271
- `git -C ${shellQuote(repoWorkdir)} fetch origin ${shellQuote(`+refs/heads/${baseBranch}:refs/remotes/${baseRef}`)}`
272
- );
273
- if (fetch.exitCode !== 0) {
274
- throw classifyGitFailure(fetch, "pull-failed");
275
- }
276
- });
277
- const add = await sh(
278
- sandbox,
279
- `git -C ${shellQuote(repoWorkdir)} worktree add --no-track -B ${shellQuote(branch)} ${shellQuote(worktreePath)} ${shellQuote(baseRef)}`
280
- );
281
- if (add.exitCode !== 0) {
282
- throw new WorktreeError(`git worktree add failed: ${add.stderr.trim() || add.stdout.trim()}`, "worktree-failed");
283
- }
284
- return { worktreePath, branch, baseBranch, reused: false };
326
+ /**
327
+ * Create (or reuse) a git worktree + branch inside the sandbox for a unit of
328
+ * work. Idempotent: if a worktree already exists at the computed path it is
329
+ * reused. The branch is created from the freshly fetched `origin/<baseBranch>`
330
+ * — never the sandbox's possibly stale local ref — so new worktrees always
331
+ * start from the latest remote state.
332
+ *
333
+ * @param sandbox live sandbox containing the base checkout
334
+ * @param repoWorkdir the base repo checkout path inside the sandbox
335
+ * @param branch the feature branch (ref-validated server-side)
336
+ * @param baseBranch the branch to fork from (ref-validated; defaults to the repo's default branch)
337
+ * @param token short-lived installation token used only for the base-branch fetch
338
+ * @param repoFullName `owner/repo` used to build the tokenized remote URL
339
+ */
340
+ async function ensureWorktree(sandbox, repoWorkdir, { branch, baseBranch, token, repoFullName }) {
341
+ if (!isValidGitRef(branch)) throw new WorktreeError(`Invalid branch name '${branch}'.`, "invalid-branch");
342
+ if (!isValidGitRef(baseBranch)) throw new WorktreeError(`Invalid base branch name '${baseBranch}'.`, "invalid-branch");
343
+ const worktreePath = computeWorktreePath(repoWorkdir, branch);
344
+ if ((await sh(sandbox, `test -e ${shellQuote(`${worktreePath}/.git`)}`)).exitCode === 0) return {
345
+ worktreePath,
346
+ branch,
347
+ baseBranch,
348
+ reused: true
349
+ };
350
+ const baseRef = `origin/${baseBranch}`;
351
+ await withInstallToken(sandbox, repoWorkdir, repoFullName, token, async () => {
352
+ const fetch = await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} fetch origin ${shellQuote(`+refs/heads/${baseBranch}:refs/remotes/${baseRef}`)}`);
353
+ if (fetch.exitCode !== 0) throw classifyGitFailure(fetch, "pull-failed");
354
+ });
355
+ const add = await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} worktree add --no-track -B ${shellQuote(branch)} ${shellQuote(worktreePath)} ${shellQuote(baseRef)}`);
356
+ if (add.exitCode !== 0) throw new WorktreeError(`git worktree add failed: ${add.stderr.trim() || add.stdout.trim()}`, "worktree-failed");
357
+ return {
358
+ worktreePath,
359
+ branch,
360
+ baseBranch,
361
+ reused: false
362
+ };
285
363
  }
364
+ /**
365
+ * Run the project's setup command (e.g. `pnpm i && pnpm build`) inside a
366
+ * freshly created worktree. Called before the worktree is handed to any agent
367
+ * run so the checkout is ready to build/test. A non-zero exit is a hard error —
368
+ * starting agent work in a half-set-up tree is worse than failing the request.
369
+ *
370
+ * Security model: the command is intentionally arbitrary shell — that is the
371
+ * feature (install deps, build, seed fixtures). It is only configurable by
372
+ * authenticated org members (the settings route is gated by
373
+ * `resolveOrgTenant` + org-scoped project lookup, with length and
374
+ * control-character validation), and it executes exclusively inside the
375
+ * project's isolated sandbox — the same environment where org members already
376
+ * run arbitrary shell via the agent's command tool. It never runs on the web
377
+ * server host, so it grants no privilege beyond what sandbox access already
378
+ * provides.
379
+ *
380
+ * @param sandbox live sandbox containing the worktree
381
+ * @param worktreePath the server-computed worktree path the command runs in
382
+ * @param command the org-configured setup shell command
383
+ */
286
384
  async function runWorktreeSetup(sandbox, worktreePath, command) {
287
- const result = await sh(sandbox, `cd ${shellQuote(worktreePath)} && { ${command}
288
- }`);
289
- if (result.exitCode !== 0) {
290
- const detail = (result.stderr.trim() || result.stdout.trim()).slice(-2e3);
291
- throw new WorktreeError(`Setup command failed (exit ${result.exitCode}): ${detail}`, "setup-failed");
292
- }
385
+ const result = await sh(sandbox, `cd ${shellQuote(worktreePath)} && { ${command}\n}`, { phase: "worktree setup" });
386
+ if (result.exitCode !== 0) {
387
+ const detail = (result.stderr.trim() || result.stdout.trim()).slice(-2e3);
388
+ throw new WorktreeError(`Setup command failed (exit ${result.exitCode}): ${detail}`, "setup-failed");
389
+ }
293
390
  }
391
+ /**
392
+ * Remove a worktree (and its local feature branch) from the sandbox. The
393
+ * checkout is removed with `--force` — the caller owns confirming that any
394
+ * uncommitted work in it can be discarded. Idempotent: a worktree whose
395
+ * directory is already gone only has its metadata pruned.
396
+ *
397
+ * @param sandbox live sandbox containing the base checkout
398
+ * @param repoWorkdir the base repo checkout path inside the sandbox
399
+ * @param branch the worktree's feature branch (ref-validated)
400
+ * @param worktreePath the persisted, server-computed worktree path
401
+ */
294
402
  async function removeWorktree(sandbox, repoWorkdir, { branch, worktreePath }) {
295
- if (!isValidGitRef(branch)) {
296
- throw new WorktreeError(`Invalid branch name '${branch}'.`, "invalid-branch");
297
- }
298
- const remove = await sh(
299
- sandbox,
300
- `git -C ${shellQuote(repoWorkdir)} worktree remove --force ${shellQuote(worktreePath)}`
301
- );
302
- if (remove.exitCode !== 0) {
303
- await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} worktree prune`);
304
- const exists = await sh(sandbox, `test -e ${shellQuote(worktreePath)}`);
305
- if (exists.exitCode === 0) {
306
- throw new WorktreeError(
307
- `git worktree remove failed: ${remove.stderr.trim() || remove.stdout.trim()}`,
308
- "worktree-failed"
309
- );
310
- }
311
- }
312
- await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} branch -D ${shellQuote(branch)}`);
403
+ if (!isValidGitRef(branch)) throw new WorktreeError(`Invalid branch name '${branch}'.`, "invalid-branch");
404
+ const remove = await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} worktree remove --force ${shellQuote(worktreePath)}`);
405
+ if (remove.exitCode !== 0) {
406
+ await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} worktree prune`);
407
+ if ((await sh(sandbox, `test -e ${shellQuote(worktreePath)}`)).exitCode === 0) throw new WorktreeError(`git worktree remove failed: ${remove.stderr.trim() || remove.stdout.trim()}`, "worktree-failed");
408
+ }
409
+ await sh(sandbox, `git -C ${shellQuote(repoWorkdir)} branch -D ${shellQuote(branch)}`);
313
410
  }
411
+ /**
412
+ * Preflight that `gh` is installed in the sandbox. Only called on the PR path so
413
+ * a missing `gh` never blocks clone/open. Surfaces an actionable error naming
414
+ * the sandbox template requirement.
415
+ */
314
416
  async function assertGhAvailable(sandbox) {
315
- const version = await sh(sandbox, "gh --version");
316
- if (version.exitCode !== 0) {
317
- throw new MaterializeError(
318
- "The GitHub CLI (gh) is not installed in the sandbox. The sandbox template must include gh to open pull requests.",
319
- "gh-missing"
320
- );
321
- }
417
+ if ((await sh(sandbox, "gh --version")).exitCode !== 0) throw new MaterializeError("The GitHub CLI (gh) is not installed in the sandbox. The sandbox template must include gh to open pull requests.", "gh-missing");
322
418
  }
419
+ /** Match the first GitHub PR URL in `gh pr create` output. */
323
420
  function parsePullRequestUrl(stdout) {
324
- const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
325
- return match?.[0];
421
+ return stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/)?.[0];
326
422
  }
423
+ /**
424
+ * Open a pull request from inside the sandbox via `gh pr create`. The token is
425
+ * passed only through a per-invocation `GH_TOKEN` env scoped to the single `gh`
426
+ * process (never persisted), all arguments are shell-quoted, and the resulting
427
+ * PR URL is parsed from stdout.
428
+ *
429
+ * @param sandbox live sandbox containing the checkout
430
+ * @param workdir the worktree (or repo) path the PR head branch is checked out in
431
+ */
327
432
  async function createPullRequest(sandbox, workdir, { token, base, head, title, body }) {
328
- if (!isValidGitRef(base)) {
329
- throw new MaterializeError(`Refusing to open PR: invalid base branch '${base}'.`, "pr-failed");
330
- }
331
- if (!isValidGitRef(head)) {
332
- throw new MaterializeError(`Refusing to open PR: invalid head branch '${head}'.`, "pr-failed");
333
- }
334
- await assertGhAvailable(sandbox);
335
- const ghCommand = [
336
- `GH_TOKEN=${shellQuote(token)} gh pr create`,
337
- `--base ${shellQuote(base)}`,
338
- `--head ${shellQuote(head)}`,
339
- `--title ${shellQuote(title)}`,
340
- `--body ${shellQuote(body ?? "")}`
341
- ].join(" ");
342
- const script = `cd ${shellQuote(workdir)} && ${ghCommand}`;
343
- const result = await sh(sandbox, script);
344
- if (result.exitCode !== 0) {
345
- const classified = classifyGitFailure(result, "push-failed");
346
- if (classified.code === "egress-blocked") {
347
- throw classified;
348
- }
349
- throw new MaterializeError(`gh pr create failed: ${result.stderr.trim() || result.stdout.trim()}`, "pr-failed");
350
- }
351
- const url = parsePullRequestUrl(result.stdout);
352
- if (!url) {
353
- throw new MaterializeError(
354
- `gh pr create succeeded but no PR URL was found in its output: ${result.stdout.trim()}`,
355
- "pr-failed"
356
- );
357
- }
358
- return { url };
433
+ if (!isValidGitRef(base)) throw new MaterializeError(`Refusing to open PR: invalid base branch '${base}'.`, "pr-failed");
434
+ if (!isValidGitRef(head)) throw new MaterializeError(`Refusing to open PR: invalid head branch '${head}'.`, "pr-failed");
435
+ await assertGhAvailable(sandbox);
436
+ const ghCommand = [
437
+ `GH_TOKEN=${shellQuote(token)} gh pr create`,
438
+ `--base ${shellQuote(base)}`,
439
+ `--head ${shellQuote(head)}`,
440
+ `--title ${shellQuote(title)}`,
441
+ `--body ${shellQuote(body ?? "")}`
442
+ ].join(" ");
443
+ const result = await sh(sandbox, `cd ${shellQuote(workdir)} && ${ghCommand}`);
444
+ if (result.exitCode !== 0) {
445
+ const classified = classifyGitFailure(result, "push-failed");
446
+ if (classified.code === "egress-blocked") throw classified;
447
+ throw new MaterializeError(`gh pr create failed: ${result.stderr.trim() || result.stdout.trim()}`, "pr-failed");
448
+ }
449
+ const url = parsePullRequestUrl(result.stdout);
450
+ if (!url) throw new MaterializeError(`gh pr create succeeded but no PR URL was found in its output: ${result.stdout.trim()}`, "pr-failed");
451
+ return { url };
359
452
  }
360
- export {
361
- MaterializeError,
362
- WorktreeError,
363
- checkoutSessionBranch,
364
- commitAll,
365
- computeWorktreePath,
366
- configureGitIdentity,
367
- createPullRequest,
368
- ensureProjectSandbox,
369
- ensureWorktree,
370
- isValidGitRef,
371
- materializeRepo,
372
- pushBranch,
373
- removeWorktree,
374
- resolveGitIdentity,
375
- runWorktreeSetup,
376
- safeBranchDir,
377
- shellQuote,
378
- teardownProjectSandbox,
379
- withInstallToken
380
- };
453
+ //#endregion
454
+ export { CHECKOUT_COMMAND_TIMEOUT_MS, MaterializeError, WorktreeError, checkoutSessionBranch, commitAll, computeWorktreePath, configureGitIdentity, createPullRequest, ensureProjectSandbox, ensureWorktree, isValidGitRef, materializeRepo, pushBranch, removeWorktree, resolveGitIdentity, runWorktreeSetup, safeBranchDir, shellQuote, teardownProjectSandbox, withInstallToken };
455
+
381
456
  //# sourceMappingURL=sandbox.js.map