@mastra/factory 0.16.0-alpha.9 → 0.16.0

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 (216) hide show
  1. package/README.md +37 -0
  2. package/dist/auth.js +1 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/boards/review.d.ts.map +1 -1
  5. package/dist/boards/review.js +19 -8
  6. package/dist/boards/review.js.map +1 -1
  7. package/dist/boards/work.d.ts.map +1 -1
  8. package/dist/boards/work.js +31 -3
  9. package/dist/boards/work.js.map +1 -1
  10. package/dist/capabilities/intake.d.ts +4 -0
  11. package/dist/capabilities/intake.d.ts.map +1 -1
  12. package/dist/capabilities/version-control.d.ts +15 -0
  13. package/dist/capabilities/version-control.d.ts.map +1 -1
  14. package/dist/factory.d.ts +7 -7
  15. package/dist/factory.d.ts.map +1 -1
  16. package/dist/factory.js +100 -40
  17. package/dist/factory.js.map +1 -1
  18. package/dist/integrations/base.d.ts +3 -0
  19. package/dist/integrations/base.d.ts.map +1 -1
  20. package/dist/integrations/github/integration.d.ts.map +1 -1
  21. package/dist/integrations/github/integration.js +21 -0
  22. package/dist/integrations/github/integration.js.map +1 -1
  23. package/dist/integrations/github/routes.js +2 -1
  24. package/dist/integrations/github/routes.js.map +1 -1
  25. package/dist/integrations/github/sandbox.d.ts +41 -42
  26. package/dist/integrations/github/sandbox.d.ts.map +1 -1
  27. package/dist/integrations/github/sandbox.js +416 -227
  28. package/dist/integrations/github/sandbox.js.map +1 -1
  29. package/dist/integrations/github/webhook.d.ts +2 -5
  30. package/dist/integrations/github/webhook.d.ts.map +1 -1
  31. package/dist/integrations/github/webhook.js +8 -48
  32. package/dist/integrations/github/webhook.js.map +1 -1
  33. package/dist/integrations/gitlab/agent-tools.d.ts +14 -0
  34. package/dist/integrations/gitlab/agent-tools.d.ts.map +1 -0
  35. package/dist/integrations/gitlab/agent-tools.js +42 -0
  36. package/dist/integrations/gitlab/agent-tools.js.map +1 -0
  37. package/dist/integrations/gitlab/api.d.ts +222 -0
  38. package/dist/integrations/gitlab/api.d.ts.map +1 -0
  39. package/dist/integrations/gitlab/api.js +274 -0
  40. package/dist/integrations/gitlab/api.js.map +1 -0
  41. package/dist/integrations/gitlab/default-rules.d.ts +125 -0
  42. package/dist/integrations/gitlab/default-rules.d.ts.map +1 -0
  43. package/dist/integrations/gitlab/default-rules.js +172 -0
  44. package/dist/integrations/gitlab/default-rules.js.map +1 -0
  45. package/dist/integrations/gitlab/integration.d.ts +138 -0
  46. package/dist/integrations/gitlab/integration.d.ts.map +1 -0
  47. package/dist/integrations/gitlab/integration.js +705 -0
  48. package/dist/integrations/gitlab/integration.js.map +1 -0
  49. package/dist/integrations/gitlab/issue-reconciler.d.ts +6 -0
  50. package/dist/integrations/gitlab/issue-reconciler.d.ts.map +1 -0
  51. package/dist/integrations/gitlab/issue-reconciler.js +123 -0
  52. package/dist/integrations/gitlab/issue-reconciler.js.map +1 -0
  53. package/dist/integrations/gitlab/merge-request-reconciler.d.ts +6 -0
  54. package/dist/integrations/gitlab/merge-request-reconciler.d.ts.map +1 -0
  55. package/dist/integrations/gitlab/merge-request-reconciler.js +267 -0
  56. package/dist/integrations/gitlab/merge-request-reconciler.js.map +1 -0
  57. package/dist/integrations/gitlab/reconciler.d.ts +5 -0
  58. package/dist/integrations/gitlab/reconciler.d.ts.map +1 -0
  59. package/dist/integrations/gitlab/reconciler.js +31 -0
  60. package/dist/integrations/gitlab/reconciler.js.map +1 -0
  61. package/dist/integrations/gitlab/reconciliation-config.d.ts +3 -0
  62. package/dist/integrations/gitlab/reconciliation-config.d.ts.map +1 -0
  63. package/dist/integrations/gitlab/reconciliation-config.js +17 -0
  64. package/dist/integrations/gitlab/reconciliation-config.js.map +1 -0
  65. package/dist/integrations/gitlab/routes.d.ts +20 -0
  66. package/dist/integrations/gitlab/routes.d.ts.map +1 -0
  67. package/dist/integrations/gitlab/routes.js +473 -0
  68. package/dist/integrations/gitlab/routes.js.map +1 -0
  69. package/dist/integrations/gitlab/rules.d.ts +35 -0
  70. package/dist/integrations/gitlab/rules.d.ts.map +1 -0
  71. package/dist/integrations/gitlab/rules.js +391 -0
  72. package/dist/integrations/gitlab/rules.js.map +1 -0
  73. package/dist/integrations/gitlab/session-subscriptions.d.ts +32 -0
  74. package/dist/integrations/gitlab/session-subscriptions.d.ts.map +1 -0
  75. package/dist/integrations/gitlab/session-subscriptions.js +192 -0
  76. package/dist/integrations/gitlab/session-subscriptions.js.map +1 -0
  77. package/dist/integrations/gitlab/subscriptions.d.ts +70 -0
  78. package/dist/integrations/gitlab/subscriptions.d.ts.map +1 -0
  79. package/dist/integrations/gitlab/subscriptions.js +76 -0
  80. package/dist/integrations/gitlab/subscriptions.js.map +1 -0
  81. package/dist/integrations/gitlab/version-control.d.ts +16 -0
  82. package/dist/integrations/gitlab/version-control.d.ts.map +1 -0
  83. package/dist/integrations/gitlab/version-control.js +622 -0
  84. package/dist/integrations/gitlab/version-control.js.map +1 -0
  85. package/dist/integrations/gitlab/webhook-dispatch.d.ts +75 -0
  86. package/dist/integrations/gitlab/webhook-dispatch.d.ts.map +1 -0
  87. package/dist/integrations/gitlab/webhook-dispatch.js +235 -0
  88. package/dist/integrations/gitlab/webhook-dispatch.js.map +1 -0
  89. package/dist/integrations/gitlab/webhook.d.ts +72 -0
  90. package/dist/integrations/gitlab/webhook.d.ts.map +1 -0
  91. package/dist/integrations/gitlab/webhook.js +201 -0
  92. package/dist/integrations/gitlab/webhook.js.map +1 -0
  93. package/dist/integrations/incidentio/integration.js +1 -1
  94. package/dist/integrations/issue-reconciler.d.ts +3 -3
  95. package/dist/integrations/issue-reconciler.d.ts.map +1 -1
  96. package/dist/integrations/issue-reconciler.js +22 -2
  97. package/dist/integrations/issue-reconciler.js.map +1 -1
  98. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  99. package/dist/integrations/platform/github/integration.js +21 -0
  100. package/dist/integrations/platform/github/integration.js.map +1 -1
  101. package/dist/integrations/platform/gitlab/event-worker.d.ts +64 -0
  102. package/dist/integrations/platform/gitlab/event-worker.d.ts.map +1 -0
  103. package/dist/integrations/platform/gitlab/event-worker.js +306 -0
  104. package/dist/integrations/platform/gitlab/event-worker.js.map +1 -0
  105. package/dist/integrations/platform/gitlab/integration.d.ts +57 -0
  106. package/dist/integrations/platform/gitlab/integration.d.ts.map +1 -0
  107. package/dist/integrations/platform/gitlab/integration.js +125 -0
  108. package/dist/integrations/platform/gitlab/integration.js.map +1 -0
  109. package/dist/integrations/platform/incidentio/integration.js +1 -1
  110. package/dist/integrations/slack/integration.d.ts.map +1 -1
  111. package/dist/integrations/slack/integration.js +3 -3
  112. package/dist/integrations/slack/integration.js.map +1 -1
  113. package/dist/integrations/slack/slack.d.ts +2 -0
  114. package/dist/integrations/slack/slack.d.ts.map +1 -1
  115. package/dist/integrations/slack/slack.js +21 -7
  116. package/dist/integrations/slack/slack.js.map +1 -1
  117. package/dist/integrations/subscription-session.d.ts +37 -0
  118. package/dist/integrations/subscription-session.d.ts.map +1 -0
  119. package/dist/integrations/subscription-session.js +83 -0
  120. package/dist/integrations/subscription-session.js.map +1 -0
  121. package/dist/packages/_internals/workspace/dist/index.js +1 -11
  122. package/dist/packages/_internals/workspace/dist/index.js.map +1 -1
  123. package/dist/routes/intake.d.ts.map +1 -1
  124. package/dist/routes/intake.js +4 -2
  125. package/dist/routes/intake.js.map +1 -1
  126. package/dist/routes/oauth.js +1 -1
  127. package/dist/routes/projects.d.ts +1 -0
  128. package/dist/routes/projects.d.ts.map +1 -1
  129. package/dist/routes/projects.js +1 -0
  130. package/dist/routes/projects.js.map +1 -1
  131. package/dist/routes/source-control-sessions.d.ts +20 -0
  132. package/dist/routes/source-control-sessions.d.ts.map +1 -0
  133. package/dist/routes/source-control-sessions.js +319 -0
  134. package/dist/routes/source-control-sessions.js.map +1 -0
  135. package/dist/routes/source-control-settings.d.ts +10 -0
  136. package/dist/routes/source-control-settings.d.ts.map +1 -0
  137. package/dist/routes/source-control-settings.js +87 -0
  138. package/dist/routes/source-control-settings.js.map +1 -0
  139. package/dist/routes/surface.d.ts +3 -3
  140. package/dist/routes/surface.d.ts.map +1 -1
  141. package/dist/routes/surface.js +55 -13
  142. package/dist/routes/surface.js.map +1 -1
  143. package/dist/routes/tenant-credentials.d.ts +8 -0
  144. package/dist/routes/tenant-credentials.d.ts.map +1 -1
  145. package/dist/routes/tenant-credentials.js +20 -3
  146. package/dist/routes/tenant-credentials.js.map +1 -1
  147. package/dist/routes/work-items.d.ts.map +1 -1
  148. package/dist/routes/work-items.js +1 -1
  149. package/dist/routes/work-items.js.map +1 -1
  150. package/dist/rules/dispatcher.d.ts.map +1 -1
  151. package/dist/rules/dispatcher.js +7 -1
  152. package/dist/rules/dispatcher.js.map +1 -1
  153. package/dist/rules/start-coordinator.d.ts +1 -1
  154. package/dist/rules/start-coordinator.d.ts.map +1 -1
  155. package/dist/rules/start-coordinator.js +3 -2
  156. package/dist/rules/start-coordinator.js.map +1 -1
  157. package/dist/rules/transition-service.d.ts.map +1 -1
  158. package/dist/rules/transition-service.js +6 -3
  159. package/dist/rules/transition-service.js.map +1 -1
  160. package/dist/rules/types.d.ts +67 -3
  161. package/dist/rules/types.d.ts.map +1 -1
  162. package/dist/rules/types.js +23 -2
  163. package/dist/rules/types.js.map +1 -1
  164. package/dist/rules/validation.d.ts.map +1 -1
  165. package/dist/rules/validation.js +2 -0
  166. package/dist/rules/validation.js.map +1 -1
  167. package/dist/sandbox/git-ref.d.ts +7 -0
  168. package/dist/sandbox/git-ref.d.ts.map +1 -0
  169. package/dist/sandbox/git-ref.js +15 -0
  170. package/dist/sandbox/git-ref.js.map +1 -0
  171. package/dist/sandbox/session-retirement.js +1 -1
  172. package/dist/sandbox/workdir.d.ts.map +1 -1
  173. package/dist/sandbox/workdir.js +6 -6
  174. package/dist/sandbox/workdir.js.map +1 -1
  175. package/dist/session/factory-session.d.ts +31 -1
  176. package/dist/session/factory-session.d.ts.map +1 -1
  177. package/dist/session/factory-session.js +60 -1
  178. package/dist/session/factory-session.js.map +1 -1
  179. package/dist/session/source-control-tools.d.ts +16 -0
  180. package/dist/session/source-control-tools.d.ts.map +1 -0
  181. package/dist/session/source-control-tools.js +464 -0
  182. package/dist/session/source-control-tools.js.map +1 -0
  183. package/dist/storage/domains/intake/base.d.ts +18 -0
  184. package/dist/storage/domains/intake/base.d.ts.map +1 -1
  185. package/dist/storage/domains/intake/base.js +77 -0
  186. package/dist/storage/domains/intake/base.js.map +1 -1
  187. package/dist/work-item-branch.d.ts +3 -1
  188. package/dist/work-item-branch.d.ts.map +1 -1
  189. package/dist/work-item-branch.js +22 -4
  190. package/dist/work-item-branch.js.map +1 -1
  191. package/dist/workspace.d.ts +15 -1
  192. package/dist/workspace.d.ts.map +1 -1
  193. package/dist/workspace.js +57 -23
  194. package/dist/workspace.js.map +1 -1
  195. package/factory-skills/factory-gitlab-rereview/SKILL.md +19 -0
  196. package/factory-skills/factory-gitlab-review/SKILL.md +30 -0
  197. package/factory-skills/factory-rereview/SKILL.md +13 -6
  198. package/factory-skills/factory-review/SKILL.md +13 -6
  199. package/factory-skills/factory-review/references/archaeology.md +225 -0
  200. package/factory-skills/factory-review/references/categories/README.md +34 -0
  201. package/factory-skills/factory-review/references/categories/behavior-change.md +36 -0
  202. package/factory-skills/factory-review/references/categories/bug-fix.md +37 -0
  203. package/factory-skills/factory-review/references/categories/docs.md +32 -0
  204. package/factory-skills/factory-review/references/categories/experimental-flagged.md +34 -0
  205. package/factory-skills/factory-review/references/categories/infra-tooling.md +37 -0
  206. package/factory-skills/factory-review/references/categories/internal-capability.md +35 -0
  207. package/factory-skills/factory-review/references/categories/large-cross-cutting.md +35 -0
  208. package/factory-skills/factory-review/references/categories/mechanical.md +36 -0
  209. package/factory-skills/factory-review/references/categories/performance.md +33 -0
  210. package/factory-skills/factory-review/references/categories/public-api.md +38 -0
  211. package/factory-skills/factory-review/references/categories/refactor.md +35 -0
  212. package/factory-skills/factory-review/references/categories/revert.md +31 -0
  213. package/factory-skills/factory-review/references/categories/schema-storage.md +39 -0
  214. package/factory-skills/factory-review/references/categories/security.md +35 -0
  215. package/factory-skills/factory-review/references/categories/tests-only.md +38 -0
  216. package/package.json +9 -9
@@ -1,23 +1,7 @@
1
1
  import { timedPhase } from "../../timing.js";
2
- import { repoCloneCommand } from "../../packages/_internals/workspace/dist/index.js";
2
+ import { isValidGitRef } from "../../sandbox/git-ref.js";
3
3
  //#region src/integrations/github/sandbox.ts
4
4
  /**
5
- * Repo materialization for GitHub-backed repositories.
6
- *
7
- * A GitHub repo is never cloned onto the server host. The repo is cloned
8
- * *inside* the session's sandbox, so the agent's file tools and command tools
9
- * operate entirely against the remote checkout.
10
- *
11
- * - `materializeRepo(row, token)` clones the repo inside the sandbox when no
12
- * checkout exists yet (a base-image boot, a wiped disk), using a short-lived
13
- * installation token that is scrubbed from the git remote afterwards so it
14
- * never persists in the VM. A checkout that is already there, from a repo
15
- * template image or an earlier start, is left exactly as it is.
16
- *
17
- * This module owns everything git/GitHub: clone, commit/push, setup/teardown commands,
18
- * and `gh pr create`. Workdir layout lives in `../sandbox/workdir`.
19
- */
20
- /**
21
5
  * Single-quote a string for safe POSIX shell interpolation. Wraps the value in
22
6
  * single quotes and escapes any embedded single quote using the canonical
23
7
  * close-quote / escaped-quote / reopen-quote sequence (`'\''`). This is the
@@ -38,6 +22,7 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 15 * 6e4;
38
22
  const CHECKOUT_COMMAND_TIMEOUT_MS = 5 * 6e4;
39
23
  /**
40
24
  * A thrown transport-level failure that is worth retrying: remote sandbox
25
+
41
26
  * providers (e.g. the platform workspace proxy) surface transient 5xx errors
42
27
  * as exceptions carrying an HTTP `status` — typically while a freshly
43
28
  * provisioned VM is still coming up. Command failures are NOT exceptions
@@ -51,18 +36,24 @@ function isTransientTransportError(error) {
51
36
  const SH_RETRIES = 2;
52
37
  const SH_RETRY_DELAY_MS = 2e3;
53
38
  /**
54
- * Run a shell script in the sandbox via `sh -c`, bounded by a hang guard.
55
- * Transient transport-level 5xx failures (proxy hiccups while the VM boots)
56
- * are retried with a short backoff; every script routed through here is safe
57
- * to re-run. Hang-guard timeouts are NOT retried — the budget applies to the
58
- * command as a whole.
39
+ * Run a shell script in the sandbox via `sh -c`. This is reserved for the
40
+ * explicitly configured repository lifecycle hooks; programmatic commands use
41
+ * {@link execute} with an argv array.
59
42
  */
60
43
  async function sh(sandbox, script, options = {}) {
44
+ return execute(sandbox, "sh", ["-c", script], options);
45
+ }
46
+ /**
47
+ * Execute one program with an argv array, bounded by a hang guard. Transient
48
+ * sandbox transport failures are retried without ever interpolating arguments
49
+ * into a shell command.
50
+ */
51
+ async function execute(sandbox, command, args, options = {}) {
61
52
  const deadlineMs = Date.now() + (options.timeoutMs ?? 9e5);
62
53
  for (let attempt = 0;; attempt++) {
63
54
  const started = performance.now();
64
55
  try {
65
- const result = await shOnce(sandbox, script, {
56
+ const result = await executeOnce(sandbox, command, args, {
66
57
  ...options,
67
58
  timeoutMs: Math.max(deadlineMs - Date.now(), 1)
68
59
  });
@@ -77,8 +68,8 @@ async function sh(sandbox, script, options = {}) {
77
68
  }
78
69
  }
79
70
  }
80
- /** Single `sh -c` execution attempt, bounded by the hang guard. */
81
- async function shOnce(sandbox, script, options) {
71
+ /** Single structured command execution attempt, bounded by the hang guard. */
72
+ async function executeOnce(sandbox, command, args, options) {
82
73
  const timeoutMs = options.timeoutMs ?? 9e5;
83
74
  let timer;
84
75
  const hangGuard = new Promise((_, reject) => {
@@ -89,7 +80,11 @@ async function shOnce(sandbox, script, options) {
89
80
  timer.unref?.();
90
81
  });
91
82
  try {
92
- return await Promise.race([sandbox.executeCommand("sh", ["-c", script], { timeout: timeoutMs }), hangGuard]);
83
+ return await Promise.race([sandbox.executeCommand(command, args, {
84
+ timeout: timeoutMs,
85
+ ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
86
+ ...options.env !== void 0 ? { env: options.env } : {}
87
+ }), hangGuard]);
93
88
  } finally {
94
89
  clearTimeout(timer);
95
90
  }
@@ -123,16 +118,16 @@ function isTransientGitFailure(result) {
123
118
  * `beforeRetry` lets a call site clear whatever the aborted attempt left
124
119
  * behind — a half-written clone directory blocks the next `git clone` outright.
125
120
  */
126
- async function gitTransfer(sandbox, script, options = {}) {
127
- const { beforeRetry, ...shOptions } = options;
128
- const deadlineMs = Date.now() + (shOptions.timeoutMs ?? 9e5);
121
+ async function gitTransfer(sandbox, args, options = {}) {
122
+ const { beforeRetry, ...commandOptions } = options;
123
+ const deadlineMs = Date.now() + (commandOptions.timeoutMs ?? 9e5);
129
124
  for (let attempt = 0;; attempt++) {
130
- const result = await sh(sandbox, script, {
131
- ...shOptions,
125
+ const result = await execute(sandbox, "git", args, {
126
+ ...commandOptions,
132
127
  timeoutMs: Math.max(deadlineMs - Date.now(), 1)
133
128
  });
134
129
  if (result.exitCode === 0 || attempt >= GIT_TRANSFER_RETRIES || !isTransientGitFailure(result)) return result;
135
- process.stderr.write(`[factory:timing] git ${shOptions.phase ?? "transfer"} retrying after attempt ${attempt + 1}\n`);
130
+ process.stderr.write(`[factory:timing] git ${commandOptions.phase ?? "transfer"} retrying after attempt ${attempt + 1}\n`);
136
131
  const delayMs = GIT_TRANSFER_RETRY_DELAY_MS * (attempt + 1);
137
132
  if (deadlineMs - Date.now() <= delayMs) return result;
138
133
  await new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -148,21 +143,72 @@ var MaterializeError = class extends Error {
148
143
  this.name = "MaterializeError";
149
144
  }
150
145
  };
146
+ function cleanUrl(repoFullName) {
147
+ return `https://github.com/${repoFullName}.git`;
148
+ }
149
+ function credentialScope(cloneUrl, code = "clone-failed") {
150
+ let url;
151
+ try {
152
+ url = new URL(cloneUrl);
153
+ } catch {
154
+ throw new MaterializeError("Refusing to configure credentials for an invalid repository clone URL.", code);
155
+ }
156
+ if (url.protocol !== "https:" || !url.hostname || url.username || url.password || url.search || url.hash) throw new MaterializeError("Refusing to configure credentials for an invalid repository clone URL.", code);
157
+ const repositoryPath = url.pathname.replace(/\/+$/, "");
158
+ if (!repositoryPath || repositoryPath === "/") throw new MaterializeError("Refusing to configure credentials for an invalid repository clone URL.", code);
159
+ return url.origin + repositoryPath;
160
+ }
151
161
  /**
152
- * Build the token-auth clone/pull URL for a repo. The token lives only inside
153
- * this URL and is scrubbed from the remote after the operation.
162
+ * Provide HTTP Basic credentials to one Git process without putting a secret
163
+ * in argv, a remote URL, or persistent git config. Git reads the URL-scoped
164
+ * extra header from its process environment and discards it on exit.
154
165
  */
155
- function tokenUrl(repoFullName, token) {
156
- return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
166
+ function gitAuthenticationEnvironment(cloneUrl, token, username, code = "clone-failed") {
167
+ const scope = credentialScope(cloneUrl, code);
168
+ if (!token || !username) throw new MaterializeError("Repository access did not include usable credentials.", code);
169
+ const authorization = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
170
+ return {
171
+ GIT_CONFIG_COUNT: "1",
172
+ GIT_CONFIG_KEY_0: `http.${scope}.extraHeader`,
173
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${authorization}`,
174
+ GIT_TERMINAL_PROMPT: "0"
175
+ };
157
176
  }
158
- function cleanUrl(repoFullName) {
159
- return `https://github.com/${repoFullName}.git`;
177
+ function normalizedRemoteUrl(value) {
178
+ let url;
179
+ try {
180
+ url = new URL(value);
181
+ } catch {
182
+ return null;
183
+ }
184
+ if (url.protocol !== "https:" || !url.hostname || url.search || url.hash) return null;
185
+ const pathname = url.pathname.replace(/\/+$/, "").replace(/\.git$/i, "");
186
+ const repositoryPath = url.hostname.toLowerCase() === "github.com" ? pathname.toLowerCase() : pathname;
187
+ return "https://" + url.host.toLowerCase() + repositoryPath;
188
+ }
189
+ async function clearWorkdir(sandbox, workdir) {
190
+ const mkdir = await execute(sandbox, "mkdir", ["-p", workdir]);
191
+ if (mkdir.exitCode !== 0) throw classifyGitFailure(mkdir, "clone-failed");
192
+ const clear = await execute(sandbox, "find", [
193
+ workdir,
194
+ "-mindepth",
195
+ "1",
196
+ "-maxdepth",
197
+ "1",
198
+ "-exec",
199
+ "rm",
200
+ "-rf",
201
+ "--",
202
+ "{}",
203
+ "+"
204
+ ]);
205
+ if (clear.exitCode !== 0) throw classifyGitFailure(clear, "clone-failed");
160
206
  }
161
207
  /**
162
208
  * Materialize the repo inside the user's sandbox: clone when no checkout of
163
- * this repo exists, otherwise nothing. Scrubs the install token from the
164
- * remote after a clone and sets `materialized_at` on the per-user sandbox
165
- * binding row.
209
+ * this repo exists, otherwise nothing. Credentials are process-scoped and the
210
+ * stored remote remains credential-free. Sets `materialized_at` on the
211
+ * per-user sandbox binding row.
166
212
  */
167
213
  async function materializeRepo(options) {
168
214
  return timedPhase("workspace.materialize", () => materializeRepoImpl(options));
@@ -171,35 +217,33 @@ async function materializeRepoImpl(options) {
171
217
  const { row: sandboxRow, repoInfo, sandbox, token, storage } = options;
172
218
  const workdir = sandboxRow.sandboxWorkdir;
173
219
  const repo = repoInfo.repoFullName;
174
- if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, "clone-failed");
220
+ if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(repo)) throw new MaterializeError(`Refusing to materialize: invalid repo full name '${repo}'.`, "clone-failed");
175
221
  if (!/^[A-Za-z0-9_./-]+$/.test(repoInfo.defaultBranch)) throw new MaterializeError(`Refusing to materialize: invalid default branch '${repoInfo.defaultBranch}'.`, "clone-failed");
176
- 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");
177
- const existing = await existingCheckoutRemote(sandbox, workdir, repo);
222
+ if ((await execute(sandbox, "git", ["--version"])).exitCode !== 0) throw new MaterializeError("git is not installed in the sandbox. The sandbox template must include git.", "git-missing");
223
+ const cleanCloneUrl = repoInfo.cloneUrl ?? cleanUrl(repo);
224
+ const existing = await existingCheckoutRemote(sandbox, workdir, cleanCloneUrl);
178
225
  if (existing !== null) {
179
- if (/\/\/[^/]*@/.test(existing)) await scrubRemote(sandbox, workdir, repo, true);
226
+ if (/\/\/[^/]*@/.test(existing)) await scrubRemote(sandbox, workdir, repo, cleanCloneUrl);
180
227
  } else {
181
- await sh(sandbox, `mkdir -p ${shellQuote(workdir)} && find ${shellQuote(workdir)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`);
182
- let tokenInRemote = false;
183
- try {
184
- const clone = await gitTransfer(sandbox, repoCloneCommand({
185
- cloneUrl: tokenUrl(repo, token),
186
- destination: workdir,
187
- branch: repoInfo.defaultBranch
188
- }), {
189
- phase: "repository clone",
190
- beforeRetry: async () => {
191
- await sh(sandbox, `mkdir -p ${shellQuote(workdir)} && find ${shellQuote(workdir)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`);
192
- }
193
- });
194
- if (clone.exitCode !== 0) {
195
- tokenInRemote = await hasGitDir(sandbox, workdir);
196
- throw classifyGitFailure(clone, "clone-failed");
228
+ const authEnv = gitAuthenticationEnvironment(cleanCloneUrl, token, repoInfo.authUsername ?? "x-access-token", "clone-failed");
229
+ await clearWorkdir(sandbox, workdir);
230
+ const clone = await gitTransfer(sandbox, [
231
+ "clone",
232
+ "--depth=1",
233
+ "--single-branch",
234
+ "--branch",
235
+ repoInfo.defaultBranch,
236
+ "--",
237
+ cleanCloneUrl,
238
+ workdir
239
+ ], {
240
+ env: authEnv,
241
+ phase: "repository clone",
242
+ beforeRetry: async () => {
243
+ await clearWorkdir(sandbox, workdir);
197
244
  }
198
- tokenInRemote = true;
199
- } catch (primary) {
200
- throw await scrubbedFailure(sandbox, workdir, repo, tokenInRemote, primary, "clone-failed");
201
- }
202
- await scrubRemote(sandbox, workdir, repo, tokenInRemote);
245
+ });
246
+ if (clone.exitCode !== 0) throw classifyGitFailure(clone, "clone-failed");
203
247
  }
204
248
  await storage.markMaterialized({ id: sandboxRow.id });
205
249
  }
@@ -208,61 +252,222 @@ async function materializeRepoImpl(options) {
208
252
  * answers from the session's `GH_TOKEN`, so no credential is ever written.
209
253
  */
210
254
  const GH_CREDENTIAL_HELPER = "!gh auth git-credential";
211
- /**
212
- * A pull-request session first fetches the base's whole commit history without
213
- * file contents, so `git log` and `git blame` work in the review, then the PR
214
- * head. Every other session starts from the shallow base tip as it is.
215
- */
216
- function startPointFetchCommands(workdir, { baseBranch, pullRequestNumber }, shallowClone) {
217
- const git = `git -C ${shellQuote(workdir)}`;
218
- if (pullRequestNumber === void 0) return `${git} fetch origin ${shellQuote(baseBranch)}`;
219
- return `${git} fetch ${shallowClone ? "--unshallow " : ""}--filter=blob:none origin ${shellQuote(baseBranch)} && ${git} fetch --filter=blob:none origin refs/pull/${pullRequestNumber}/head`;
255
+ async function fetchStartPoint(sandbox, workdir, { baseBranch, pullRequestNumber, mergeRequestNumber }, shallowClone, env) {
256
+ const changeRequestRef = pullRequestNumber !== void 0 ? `refs/pull/${pullRequestNumber}/head` : mergeRequestNumber !== void 0 ? `refs/merge-requests/${mergeRequestNumber}/head` : void 0;
257
+ const base = await gitTransfer(sandbox, [
258
+ "-C",
259
+ workdir,
260
+ "fetch",
261
+ ...changeRequestRef && shallowClone ? ["--unshallow"] : [],
262
+ ...pullRequestNumber !== void 0 ? ["--filter=blob:none"] : [],
263
+ "origin",
264
+ baseBranch
265
+ ], {
266
+ env,
267
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
268
+ phase: "branch checkout fetch"
269
+ });
270
+ if (base.exitCode !== 0 || !changeRequestRef) return base;
271
+ return gitTransfer(sandbox, [
272
+ "-C",
273
+ workdir,
274
+ "fetch",
275
+ ...pullRequestNumber !== void 0 ? ["--filter=blob:none"] : [],
276
+ "origin",
277
+ changeRequestRef
278
+ ], {
279
+ env,
280
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
281
+ phase: "change request head fetch"
282
+ });
220
283
  }
221
284
  /** Check out a session's branch inside its isolated repository clone. */
222
285
  async function checkoutSessionBranch(sandbox, workdir, options) {
223
286
  return timedPhase("workspace.checkout", () => checkoutSessionBranchImpl(sandbox, workdir, options));
224
287
  }
288
+ /** Refresh an existing GitLab review checkout without exposing its credential to the agent. */
289
+ async function refreshMergeRequestCheckout(sandbox, workdir, input) {
290
+ const { branch, mergeRequestNumber, expectedHeadSha, access } = input;
291
+ if (!isValidGitRef(branch) || !Number.isSafeInteger(mergeRequestNumber) || mergeRequestNumber <= 0 || !/^[0-9a-f]{40}$/i.test(expectedHeadSha)) throw new Error("Refusing to refresh a GitLab review with invalid session or head metadata.");
292
+ const token = access.authorization?.token;
293
+ if (!token) throw new Error("GitLab repository access did not include a bearer token.");
294
+ const env = gitAuthenticationEnvironment(access.cloneUrl, token, access.authorization?.username ?? "oauth2", "pull-failed");
295
+ const current = await execute(sandbox, "git", [
296
+ "-C",
297
+ workdir,
298
+ "branch",
299
+ "--show-current"
300
+ ]);
301
+ if (current.exitCode !== 0 || current.stdout.trim() !== branch) throw new Error("The active checkout is not on its bound review branch.");
302
+ const status = await execute(sandbox, "git", [
303
+ "-C",
304
+ workdir,
305
+ "status",
306
+ "--porcelain",
307
+ "--untracked-files=all"
308
+ ]);
309
+ if (status.exitCode !== 0 || status.stdout.trim()) throw new Error("The review checkout has local changes; refusing to replace them.");
310
+ const before = await execute(sandbox, "git", [
311
+ "-C",
312
+ workdir,
313
+ "rev-parse",
314
+ "HEAD"
315
+ ]);
316
+ if (before.exitCode !== 0) throw new Error("Could not read the current review checkout head.");
317
+ const fetch = await gitTransfer(sandbox, [
318
+ "-C",
319
+ workdir,
320
+ "fetch",
321
+ "origin",
322
+ `refs/merge-requests/${mergeRequestNumber}/head`
323
+ ], {
324
+ env,
325
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
326
+ phase: "GitLab review head refresh"
327
+ });
328
+ if (fetch.exitCode !== 0) throw classifyGitFailure(fetch, "pull-failed");
329
+ const fetched = await execute(sandbox, "git", [
330
+ "-C",
331
+ workdir,
332
+ "rev-parse",
333
+ "FETCH_HEAD"
334
+ ]);
335
+ if (fetched.exitCode !== 0 || fetched.stdout.trim().toLowerCase() !== expectedHeadSha.toLowerCase()) throw new Error("The fetched GitLab review head differs from the provider-reported head; retry after it settles.");
336
+ if (before.stdout.trim().toLowerCase() === expectedHeadSha.toLowerCase()) return {
337
+ headSha: expectedHeadSha,
338
+ changed: false
339
+ };
340
+ const updated = await execute(sandbox, "git", [
341
+ "-C",
342
+ workdir,
343
+ "checkout",
344
+ "-B",
345
+ branch,
346
+ "FETCH_HEAD"
347
+ ], { env });
348
+ if (updated.exitCode !== 0) throw classifyGitFailure(updated, "pull-failed");
349
+ return {
350
+ headSha: expectedHeadSha,
351
+ changed: true
352
+ };
353
+ }
225
354
  async function checkoutSessionBranchImpl(sandbox, workdir, options) {
226
- const { branch, baseBranch, token, repoFullName, pullRequestNumber } = options;
227
- if (!isValidGitRef(branch) || !isValidGitRef(baseBranch)) throw new MaterializeError("Refusing to create a session from an invalid branch name.", "clone-failed");
228
- const pullRequestSession = pullRequestNumber !== void 0;
229
- await sh(sandbox, `git -C ${shellQuote(workdir)} config credential.helper ${shellQuote(GH_CREDENTIAL_HELPER)}`);
230
- const current = await sh(sandbox, `git -C ${shellQuote(workdir)} branch --show-current`);
355
+ const { branch, baseBranch, token, repoFullName, pullRequestNumber, mergeRequestNumber, cloneUrl, authUsername } = options;
356
+ if (!isValidGitRef(branch) || !isValidGitRef(baseBranch) || pullRequestNumber !== void 0 && mergeRequestNumber !== void 0 || mergeRequestNumber !== void 0 && (!Number.isSafeInteger(mergeRequestNumber) || mergeRequestNumber <= 0)) throw new MaterializeError("Refusing to create a session from an invalid branch name.", "clone-failed");
357
+ const changeRequestSession = pullRequestNumber !== void 0 || mergeRequestNumber !== void 0;
358
+ const cleanCloneUrl = cloneUrl ?? cleanUrl(repoFullName);
359
+ const authEnv = gitAuthenticationEnvironment(cleanCloneUrl, token, authUsername ?? "x-access-token", "pull-failed");
360
+ const credentialKey = authUsername ? "credential." + credentialScope(cleanCloneUrl) + ".helper" : "credential.helper";
361
+ if (authUsername) await execute(sandbox, "git", [
362
+ "-C",
363
+ workdir,
364
+ "config",
365
+ "--unset-all",
366
+ credentialKey
367
+ ]);
368
+ else {
369
+ const configured = await execute(sandbox, "git", [
370
+ "-C",
371
+ workdir,
372
+ "config",
373
+ credentialKey,
374
+ GH_CREDENTIAL_HELPER
375
+ ]);
376
+ if (configured.exitCode !== 0) throw classifyGitFailure(configured, "pull-failed");
377
+ }
378
+ const current = await execute(sandbox, "git", [
379
+ "-C",
380
+ workdir,
381
+ "branch",
382
+ "--show-current"
383
+ ]);
231
384
  if (current.exitCode === 0 && current.stdout.trim() === branch) return;
232
- if ((await sh(sandbox, `git -C ${shellQuote(workdir)} show-ref --verify --quiet refs/heads/${shellQuote(branch)}`)).exitCode === 0) {
233
- const checkout = await sh(sandbox, `git -C ${shellQuote(workdir)} checkout ${shellQuote(branch)}`);
385
+ if ((await execute(sandbox, "git", [
386
+ "-C",
387
+ workdir,
388
+ "show-ref",
389
+ "--verify",
390
+ "--quiet",
391
+ `refs/heads/${branch}`
392
+ ])).exitCode === 0) {
393
+ const checkout = await execute(sandbox, "git", [
394
+ "-C",
395
+ workdir,
396
+ "checkout",
397
+ branch
398
+ ], { env: authEnv });
234
399
  if (checkout.exitCode !== 0) {
235
400
  if (isBlockedByLocalWork(checkout)) return;
236
401
  throw classifyGitFailure(checkout, "clone-failed");
237
402
  }
238
403
  return;
239
404
  }
240
- const shallowClone = pullRequestSession && (await sh(sandbox, `git -C ${shellQuote(workdir)} rev-parse --is-shallow-repository`)).stdout.trim() === "true";
241
- const authUrl = tokenUrl(repoFullName, token);
242
- try {
243
- const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(authUrl)}`, { phase: "branch checkout remote" });
244
- if (setUrl.exitCode !== 0) throw classifyGitFailure(setUrl, "pull-failed");
245
- const fetch = await sh(sandbox, `${startPointFetchCommands(workdir, options, shallowClone)} && git -C ${shellQuote(workdir)} checkout -b ${shellQuote(branch)} FETCH_HEAD`, {
246
- timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
247
- phase: "branch checkout"
248
- });
249
- if (fetch.exitCode !== 0) {
250
- if (isBlockedByLocalWork(fetch)) return;
251
- if (!isBranchCollision(fetch)) throw classifyGitFailure(fetch, "clone-failed");
252
- const adopt = await sh(sandbox, `git -C ${shellQuote(workdir)} checkout ${shellQuote(branch)}`);
253
- if (adopt.exitCode === 0 || isBlockedByLocalWork(adopt)) return;
254
- if ((await sh(sandbox, `git -C ${shellQuote(workdir)} update-ref --no-deref -d refs/heads/${shellQuote(branch)} || rm -f -- "$(git -C ${shellQuote(workdir)} rev-parse --absolute-git-dir)/refs/heads/${branch}"`)).exitCode !== 0) throw classifyGitFailure(fetch, "clone-failed");
255
- const retry = await sh(sandbox, `git -C ${shellQuote(workdir)} checkout -b ${shellQuote(branch)} FETCH_HEAD`, {
256
- timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
257
- phase: "branch checkout retry"
258
- });
259
- if (retry.exitCode !== 0) {
260
- if (isBlockedByLocalWork(retry)) return;
261
- throw classifyGitFailure(retry, "clone-failed");
262
- }
263
- }
264
- } finally {
265
- await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`);
405
+ const fetch = await fetchStartPoint(sandbox, workdir, options, changeRequestSession && (await execute(sandbox, "git", [
406
+ "-C",
407
+ workdir,
408
+ "rev-parse",
409
+ "--is-shallow-repository"
410
+ ])).stdout.trim() === "true", authEnv);
411
+ if (fetch.exitCode !== 0) throw classifyGitFailure(fetch, "pull-failed");
412
+ const create = await execute(sandbox, "git", [
413
+ "-C",
414
+ workdir,
415
+ "checkout",
416
+ "-b",
417
+ branch,
418
+ "FETCH_HEAD"
419
+ ], {
420
+ env: authEnv,
421
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
422
+ phase: "branch checkout"
423
+ });
424
+ if (create.exitCode === 0 || isBlockedByLocalWork(create)) return;
425
+ if (!isBranchCollision(create)) throw classifyGitFailure(create, "clone-failed");
426
+ const adopt = await execute(sandbox, "git", [
427
+ "-C",
428
+ workdir,
429
+ "checkout",
430
+ branch
431
+ ], { env: authEnv });
432
+ if (adopt.exitCode === 0 || isBlockedByLocalWork(adopt)) return;
433
+ let drop = await execute(sandbox, "git", [
434
+ "-C",
435
+ workdir,
436
+ "update-ref",
437
+ "--no-deref",
438
+ "-d",
439
+ `refs/heads/${branch}`
440
+ ]);
441
+ if (drop.exitCode !== 0) {
442
+ const gitDir = await execute(sandbox, "git", [
443
+ "-C",
444
+ workdir,
445
+ "rev-parse",
446
+ "--absolute-git-dir"
447
+ ]);
448
+ if (gitDir.exitCode !== 0 || !gitDir.stdout.trim()) throw classifyGitFailure(create, "clone-failed");
449
+ drop = await execute(sandbox, "rm", [
450
+ "-f",
451
+ "--",
452
+ `${gitDir.stdout.trim()}/refs/heads/${branch}`
453
+ ]);
454
+ }
455
+ if (drop.exitCode !== 0) throw classifyGitFailure(create, "clone-failed");
456
+ const retry = await execute(sandbox, "git", [
457
+ "-C",
458
+ workdir,
459
+ "checkout",
460
+ "-b",
461
+ branch,
462
+ "FETCH_HEAD"
463
+ ], {
464
+ env: authEnv,
465
+ timeoutMs: CHECKOUT_COMMAND_TIMEOUT_MS,
466
+ phase: "branch checkout retry"
467
+ });
468
+ if (retry.exitCode !== 0) {
469
+ if (isBlockedByLocalWork(retry)) return;
470
+ throw classifyGitFailure(retry, "clone-failed");
266
471
  }
267
472
  }
268
473
  /**
@@ -285,50 +490,35 @@ function isBlockedByLocalWork(result) {
285
490
  return /Your local changes to the following files would be overwritten by checkout|untracked working tree files would be overwritten by checkout/i.test(output);
286
491
  }
287
492
  /**
288
- * The `origin` URL of a checkout of this exact repo in the workdir, or null
289
- * when there is none. Matches both the clean and token-auth URL forms; any
290
- * other remote (or no git dir at all) sends materialize down the clone path.
493
+ * Return the origin URL when the workdir contains this repository, including a
494
+ * legacy checkout whose remote still embeds credentials.
291
495
  */
292
- async function existingCheckoutRemote(sandbox, workdir, repoFullName) {
293
- const result = await sh(sandbox, `git -C ${shellQuote(workdir)} remote get-url origin`);
496
+ async function existingCheckoutRemote(sandbox, workdir, cloneUrl) {
497
+ const result = await execute(sandbox, "git", [
498
+ "-C",
499
+ workdir,
500
+ "remote",
501
+ "get-url",
502
+ "origin"
503
+ ]);
294
504
  if (result.exitCode !== 0) return null;
295
505
  const url = result.stdout.trim();
296
- return isRemoteForRepo(url, repoFullName) ? url : null;
506
+ const actual = normalizedRemoteUrl(url);
507
+ const expected = normalizedRemoteUrl(cloneUrl);
508
+ return actual !== null && expected !== null && actual === expected ? url : null;
297
509
  }
298
- /** True only for `https://github.com/<repo>[.git]`, with or without embedded credentials. */
299
- function isRemoteForRepo(url, repoFullName) {
300
- let parsed;
301
- try {
302
- parsed = new URL(url);
303
- } catch {
304
- return false;
305
- }
306
- if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "github.com") return false;
307
- if (parsed.port !== "" || parsed.search !== "" || parsed.hash !== "") return false;
308
- return parsed.pathname.replace(/\.git$/, "").toLowerCase() === `/${repoFullName.toLowerCase()}`;
309
- }
310
- /** Probed without `git -C` so a missing workdir returns false instead of throwing. */
311
- async function hasGitDir(sandbox, workdir) {
312
- return (await sh(sandbox, `test -d ${shellQuote(`${workdir}/.git`)}`).catch(() => null))?.exitCode === 0;
313
- }
314
- /**
315
- * Reset the git remote back to the tokenless URL. Strict when the token
316
- * reached the remote: any failure — a non-zero exit or a provider throw —
317
- * means the token may still be persisted, so it is thrown for the caller to
318
- * surface. Best-effort when it never did: the workdir may not exist (e.g. a
319
- * failed clone), which makes providers that spawn with `cwd` throw rather
320
- * than return a non-zero exit code; both outcomes are tolerated so neither
321
- * masks the primary failure.
322
- */
323
- async function scrubRemote(sandbox, workdir, repoFullName, tokenInRemote) {
324
- const scrub = `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(cleanUrl(repoFullName))}`;
325
- if (!tokenInRemote) {
326
- await sh(sandbox, scrub).catch(() => void 0);
327
- return;
328
- }
510
+ /** Remove credentials persisted by an older implementation from origin. */
511
+ async function scrubRemote(sandbox, workdir, repoFullName, cloneUrl = cleanUrl(repoFullName)) {
329
512
  let failure;
330
513
  try {
331
- const result = await sh(sandbox, scrub);
514
+ const result = await execute(sandbox, "git", [
515
+ "-C",
516
+ workdir,
517
+ "remote",
518
+ "set-url",
519
+ "origin",
520
+ cloneUrl
521
+ ]);
332
522
  if (result.exitCode === 0) return;
333
523
  failure = result.stderr.trim() || result.stdout.trim();
334
524
  } catch (error) {
@@ -337,23 +527,6 @@ async function scrubRemote(sandbox, workdir, repoFullName, tokenInRemote) {
337
527
  throw new MaterializeError(`Failed to scrub installation token from git remote: ${failure}`, "pull-failed");
338
528
  }
339
529
  /**
340
- * Scrub after `primary` already failed and return the error to throw. A failed
341
- * scrub is appended to `primary` rather than replacing it, so the caller gets
342
- * the error it would have had without the scrub — same class, same `code` —
343
- * carrying the leaked-token warning in its message.
344
- */
345
- async function scrubbedFailure(sandbox, workdir, repoFullName, tokenInRemote, primary, fallback) {
346
- try {
347
- await scrubRemote(sandbox, workdir, repoFullName, tokenInRemote);
348
- return primary;
349
- } catch (scrubError) {
350
- const scrubMessage = scrubError instanceof Error ? scrubError.message : String(scrubError);
351
- if (!(primary instanceof Error)) return new MaterializeError(`${String(primary)} — additionally: ${scrubMessage}`, fallback);
352
- primary.message = `${primary.message} — additionally: ${scrubMessage}`;
353
- return primary;
354
- }
355
- }
356
- /**
357
530
  * Turn a failed git command into an actionable error, detecting the common
358
531
  * "cannot reach github.com" egress failure.
359
532
  */
@@ -363,14 +536,6 @@ function classifyGitFailure(result, fallback) {
363
536
  return new MaterializeError(`git ${fallback === "clone-failed" ? "clone" : fallback === "pull-failed" ? "pull" : "push"} failed: ${stderr}`, fallback);
364
537
  }
365
538
  /**
366
- * Validate a git ref (branch) name. Server-side defense-in-depth: only allow a
367
- * conservative character set so a branch can never be built into a shell
368
- * command in a way that escapes quoting. Mirrors the route-layer check.
369
- */
370
- function isValidGitRef(value) {
371
- return typeof value === "string" && value.length > 0 && value.length <= 255 && !value.startsWith("-") && /^[A-Za-z0-9_./-]+$/.test(value);
372
- }
373
- /**
374
539
  * Resolve a concrete `{ name, email }` for git authorship from a possibly-sparse
375
540
  * identity. Falls back to a GitHub-style noreply identity so commits are never
376
541
  * authored with an empty or host-derived identity.
@@ -382,57 +547,57 @@ function resolveGitIdentity(identity) {
382
547
  email: (identity.email || "").trim() || (login ? `${login}@users.noreply.github.com` : "mastra-code@users.noreply.github.com")
383
548
  };
384
549
  }
385
- /**
386
- * Configure `user.name` / `user.email` for the given repo working tree inside
387
- * the sandbox so commits are authored correctly. Values are shell-quoted.
388
- */
550
+ /** Configure repository-local commit identity through direct Git argv calls. */
389
551
  async function configureGitIdentity(sandbox, workdir, identity) {
390
552
  const { name, email } = resolveGitIdentity(identity);
391
- const setName = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.name ${shellQuote(name)}`);
553
+ const setName = await execute(sandbox, "git", [
554
+ "-C",
555
+ workdir,
556
+ "config",
557
+ "user.name",
558
+ name
559
+ ]);
392
560
  if (setName.exitCode !== 0) throw new MaterializeError(`Failed to set git user.name: ${setName.stderr.trim()}`, "commit-failed");
393
- const setEmail = await sh(sandbox, `git -C ${shellQuote(workdir)} config user.email ${shellQuote(email)}`);
561
+ const setEmail = await execute(sandbox, "git", [
562
+ "-C",
563
+ workdir,
564
+ "config",
565
+ "user.email",
566
+ email
567
+ ]);
394
568
  if (setEmail.exitCode !== 0) throw new MaterializeError(`Failed to set git user.email: ${setEmail.stderr.trim()}`, "commit-failed");
395
569
  }
570
+ async function pushAuthenticatedBranch(sandbox, workdir, branch, cloneUrl, token, username) {
571
+ const env = gitAuthenticationEnvironment(cloneUrl, token, username, "push-failed");
572
+ const push = await execute(sandbox, "git", [
573
+ "-C",
574
+ workdir,
575
+ "push",
576
+ "-u",
577
+ "origin",
578
+ branch
579
+ ], { env });
580
+ if (push.exitCode !== 0) throw classifyGitFailure(push, "push-failed");
581
+ }
396
582
  /**
397
- * Temporarily rewrite `origin` to a tokenized URL, run `fn` (e.g. a push), and
398
- * **always** scrub the remote back to the tokenless URL afterwards. The token
399
- * therefore only ever lives in the remote URL for the duration of the
400
- * operation and is never left in the VM's git config.
401
- *
402
- * Once the tokenized URL is installed a failed scrub may leave the token
403
- * persisted, so it is always surfaced: on its own after a successful `fn`,
404
- * appended to `fn`'s own error otherwise — `fn`'s error is never replaced.
405
- * Only a failed set-url (the token never reached the remote) downgrades the
406
- * scrub to best-effort.
583
+ * Push a branch back to GitHub with credentials scoped to the Git process.
584
+ * The token never enters argv, a repository URL, or persistent configuration.
407
585
  */
408
- async function withInstallToken(sandbox, workdir, repoFullName, token, fn) {
409
- if (!/^[\w.-]+\/[\w.-]+$/.test(repoFullName)) throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
410
- const setUrl = await sh(sandbox, `git -C ${shellQuote(workdir)} remote set-url origin ${shellQuote(tokenUrl(repoFullName, token))}`);
411
- if (setUrl.exitCode !== 0) {
412
- await scrubRemote(sandbox, workdir, repoFullName, false);
413
- throw new MaterializeError(`Failed to set git remote: ${setUrl.stderr.trim()}`, "push-failed");
414
- }
415
- let result;
416
- try {
417
- result = await fn();
418
- } catch (primary) {
419
- throw await scrubbedFailure(sandbox, workdir, repoFullName, true, primary, "push-failed");
420
- }
421
- await scrubRemote(sandbox, workdir, repoFullName, true);
422
- return result;
586
+ async function pushBranch(sandbox, workdir, branch, token, repoFullName) {
587
+ if (!isValidGitRef(branch)) throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, "push-failed");
588
+ if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(repoFullName)) throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
589
+ await pushAuthenticatedBranch(sandbox, workdir, branch, cleanUrl(repoFullName), token, "x-access-token");
423
590
  }
424
591
  /**
425
- * Push a branch back to GitHub from inside the sandbox using a short-lived
426
- * installation token. The branch is ref-validated, the token is injected only
427
- * into the remote URL via `withInstallToken`, and egress failures are
428
- * classified into actionable errors.
592
+ * Push the active session branch through the provider-neutral repository
593
+ * access contract without persisting or exposing provider credentials.
429
594
  */
430
- async function pushBranch(sandbox, workdir, branch, token, repoFullName) {
595
+ async function pushRepositoryBranch(sandbox, workdir, branch, access, repoFullName) {
431
596
  if (!isValidGitRef(branch)) throw new MaterializeError(`Refusing to push: invalid branch name '${branch}'.`, "push-failed");
432
- await withInstallToken(sandbox, workdir, repoFullName, token, async () => {
433
- const push = await sh(sandbox, `git -C ${shellQuote(workdir)} push -u origin ${shellQuote(branch)}`);
434
- if (push.exitCode !== 0) throw classifyGitFailure(push, "push-failed");
435
- });
597
+ if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(repoFullName)) throw new MaterializeError(`Refusing to push: invalid repo full name '${repoFullName}'.`, "push-failed");
598
+ const authorization = access.authorization;
599
+ if (!authorization?.token) throw new MaterializeError("Repository access did not include push credentials.", "push-failed");
600
+ await pushAuthenticatedBranch(sandbox, workdir, branch, access.cloneUrl, authorization.token, authorization.username ?? "x-access-token");
436
601
  }
437
602
  /**
438
603
  * Stage every change in the working tree and create a commit inside the
@@ -442,15 +607,32 @@ async function pushBranch(sandbox, workdir, branch, token, repoFullName) {
442
607
  *
443
608
  * @param sandbox the live sandbox containing the checkout
444
609
  * @param workdir the session workdir to commit in
445
- * @param message the commit message (quoted; arbitrary text is safe)
610
+ * @param message the commit message (passed as one argv value)
446
611
  * @param identity authorship identity for the commit
447
612
  */
448
613
  async function commitAll(sandbox, workdir, message, identity) {
449
614
  await configureGitIdentity(sandbox, workdir, identity);
450
- const add = await sh(sandbox, `git -C ${shellQuote(workdir)} add -A`);
615
+ const add = await execute(sandbox, "git", [
616
+ "-C",
617
+ workdir,
618
+ "add",
619
+ "-A"
620
+ ]);
451
621
  if (add.exitCode !== 0) throw new MaterializeError(`git add failed: ${add.stderr.trim() || add.stdout.trim()}`, "commit-failed");
452
- if ((await sh(sandbox, `git -C ${shellQuote(workdir)} diff --cached --quiet`)).exitCode === 0) return { committed: false };
453
- const commit = await sh(sandbox, `git -C ${shellQuote(workdir)} commit -m ${shellQuote(message)}`);
622
+ if ((await execute(sandbox, "git", [
623
+ "-C",
624
+ workdir,
625
+ "diff",
626
+ "--cached",
627
+ "--quiet"
628
+ ])).exitCode === 0) return { committed: false };
629
+ const commit = await execute(sandbox, "git", [
630
+ "-C",
631
+ workdir,
632
+ "commit",
633
+ "-m",
634
+ message
635
+ ]);
454
636
  if (commit.exitCode !== 0) throw new MaterializeError(`git commit failed: ${commit.stderr.trim() || commit.stdout.trim()}`, "commit-failed");
455
637
  return { committed: true };
456
638
  }
@@ -515,7 +697,7 @@ async function runTeardownCommand(sandbox, workdir, command, options = {}) {
515
697
  * the sandbox template requirement.
516
698
  */
517
699
  async function assertGhAvailable(sandbox) {
518
- 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");
700
+ if ((await execute(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");
519
701
  }
520
702
  /** Match the first GitHub PR URL in `gh pr create` output. */
521
703
  function parsePullRequestUrl(stdout) {
@@ -524,8 +706,8 @@ function parsePullRequestUrl(stdout) {
524
706
  /**
525
707
  * Open a pull request from inside the sandbox via `gh pr create`. The token is
526
708
  * passed only through a per-invocation `GH_TOKEN` env scoped to the single `gh`
527
- * process (never persisted), all arguments are shell-quoted, and the resulting
528
- * PR URL is parsed from stdout.
709
+ * process (never persisted), all arguments are passed as structured argv, and
710
+ * the resulting PR URL is parsed from stdout.
529
711
  *
530
712
  * @param sandbox live sandbox containing the checkout
531
713
  * @param workdir the worktree (or repo) path the PR head branch is checked out in
@@ -534,14 +716,21 @@ async function createPullRequest(sandbox, workdir, { token, base, head, title, b
534
716
  if (!isValidGitRef(base)) throw new MaterializeError(`Refusing to open PR: invalid base branch '${base}'.`, "pr-failed");
535
717
  if (!isValidGitRef(head)) throw new MaterializeError(`Refusing to open PR: invalid head branch '${head}'.`, "pr-failed");
536
718
  await assertGhAvailable(sandbox);
537
- const ghCommand = [
538
- `GH_TOKEN=${shellQuote(token)} gh pr create`,
539
- `--base ${shellQuote(base)}`,
540
- `--head ${shellQuote(head)}`,
541
- `--title ${shellQuote(title)}`,
542
- `--body ${shellQuote(body ?? "")}`
543
- ].join(" ");
544
- const result = await sh(sandbox, `cd ${shellQuote(workdir)} && ${ghCommand}`);
719
+ const result = await execute(sandbox, "gh", [
720
+ "pr",
721
+ "create",
722
+ "--base",
723
+ base,
724
+ "--head",
725
+ head,
726
+ "--title",
727
+ title,
728
+ "--body",
729
+ body ?? ""
730
+ ], {
731
+ cwd: workdir,
732
+ env: { GH_TOKEN: token }
733
+ });
545
734
  if (result.exitCode !== 0) {
546
735
  const classified = classifyGitFailure(result, "push-failed");
547
736
  if (classified.code === "egress-blocked") throw classified;
@@ -552,6 +741,6 @@ async function createPullRequest(sandbox, workdir, { token, base, head, title, b
552
741
  return { url };
553
742
  }
554
743
  //#endregion
555
- export { CHECKOUT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_TIMEOUT_MS, MaterializeError, SetupCommandError, checkoutSessionBranch, commitAll, configureGitIdentity, createPullRequest, isValidGitRef, materializeRepo, pushBranch, resolveGitIdentity, runSetupCommand, runTeardownCommand, sh, shellQuote, withInstallToken };
744
+ export { CHECKOUT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_TIMEOUT_MS, MaterializeError, SetupCommandError, checkoutSessionBranch, commitAll, configureGitIdentity, createPullRequest, isValidGitRef, materializeRepo, pushBranch, pushRepositoryBranch, refreshMergeRequestCheckout, resolveGitIdentity, runSetupCommand, runTeardownCommand, sh, shellQuote };
556
745
 
557
746
  //# sourceMappingURL=sandbox.js.map