@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.3

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 (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -2
  3. package/bin/codeflow.mjs +8 -0
  4. package/bin/launcher.mjs +127 -0
  5. package/dist/remote-host.json +1 -0
  6. package/licenses/inter.txt +92 -0
  7. package/licenses/preact.txt +21 -0
  8. package/licenses/signals-core.txt +21 -0
  9. package/licenses/signals.txt +21 -0
  10. package/licenses/typescript-notices.txt +193 -0
  11. package/licenses/typescript.txt +55 -0
  12. package/licenses/uqr.txt +22 -0
  13. package/licenses/xterm-fit.txt +19 -0
  14. package/licenses/xterm.txt +21 -0
  15. package/package.json +34 -4
  16. package/public/assets/changes-62ZIEQSN.js +1 -0
  17. package/public/assets/chunk-2PZVOKID.js +1 -0
  18. package/public/assets/chunk-4D5IENEB.js +1 -0
  19. package/public/assets/chunk-5SARP5MU.js +1 -0
  20. package/public/assets/chunk-63YLZ3PR.js +1 -0
  21. package/public/assets/chunk-7FUHKF2M.js +1 -0
  22. package/public/assets/chunk-APQEVJ2K.js +1 -0
  23. package/public/assets/chunk-CGOZQMEZ.js +2 -0
  24. package/public/assets/chunk-J4VYS3KV.js +2 -0
  25. package/public/assets/chunk-L7TUCIYF.js +1 -0
  26. package/public/assets/chunk-SIHDWM2Y.js +154 -0
  27. package/public/assets/chunk-TEJVYIKC.js +1 -0
  28. package/public/assets/code-browser-VB2HGZSG.js +4 -0
  29. package/public/assets/filtered-tasks-ES5CP4KV.js +1 -0
  30. package/public/assets/interactive-browser-7F5ZXRUV.js +7 -0
  31. package/public/assets/join-project-6RTFHNCA.js +1 -0
  32. package/public/assets/main-65EW73SB.js +1 -0
  33. package/public/assets/project-invitations-view-GMPWGWYM.js +1 -0
  34. package/public/assets/settings-UB5Q4AQM.js +9 -0
  35. package/public/assets/styles-18792BB4.css +32 -0
  36. package/public/assets/team-server-wizard-2GLDWS6D.js +8 -0
  37. package/public/assets/terminal-IWAIM4DE.js +62 -0
  38. package/public/assets.json +1 -0
  39. package/public/bundle-meta.json +1 -0
  40. package/public/docs/.nojekyll +0 -0
  41. package/public/docs/accessibility.html +19 -0
  42. package/public/docs/acp-agents.html +117 -0
  43. package/public/docs/agent-containers.html +79 -0
  44. package/public/docs/api.html +1130 -0
  45. package/public/docs/app.js +342 -0
  46. package/public/docs/application-improvements.html +275 -0
  47. package/public/docs/architecture-explorer.html +19 -0
  48. package/public/docs/architecture.html +149 -0
  49. package/public/docs/auto-team.html +148 -0
  50. package/public/docs/automations.html +109 -0
  51. package/public/docs/checkpoint-review.html +37 -0
  52. package/public/docs/code-hosts.html +35 -0
  53. package/public/docs/code-intelligence.html +35 -0
  54. package/public/docs/configuration.html +346 -0
  55. package/public/docs/context-inspection.html +23 -0
  56. package/public/docs/coordinated-changes.html +28 -0
  57. package/public/docs/deferred-runs.html +25 -0
  58. package/public/docs/dependency-discovery.html +19 -0
  59. package/public/docs/documentation.html +84 -0
  60. package/public/docs/dynamic-teams-evaluation.html +174 -0
  61. package/public/docs/dynamic-teams-implementation-plan.html +882 -0
  62. package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
  63. package/public/docs/dynamic-teams-plan-review-0.html +204 -0
  64. package/public/docs/extensions.html +42 -0
  65. package/public/docs/external-sources.html +96 -0
  66. package/public/docs/filesystem-policy.html +26 -0
  67. package/public/docs/filtered-tasks.html +44 -0
  68. package/public/docs/font-license.txt +92 -0
  69. package/public/docs/getting-started.html +143 -0
  70. package/public/docs/github-review-threads.html +26 -0
  71. package/public/docs/github.html +157 -0
  72. package/public/docs/help.html +69 -0
  73. package/public/docs/index.html +88 -0
  74. package/public/docs/installation.html +80 -0
  75. package/public/docs/launch-profiles.html +48 -0
  76. package/public/docs/maintenance.html +60 -0
  77. package/public/docs/memory-claims.html +31 -0
  78. package/public/docs/memory-storage.html +58 -0
  79. package/public/docs/notifications.html +39 -0
  80. package/public/docs/patterns.html +195 -0
  81. package/public/docs/performance-results.html +210 -0
  82. package/public/docs/performance.html +166 -0
  83. package/public/docs/project-conventions.html +22 -0
  84. package/public/docs/project-first-navigation-plan.html +587 -0
  85. package/public/docs/project-teams-implementation-plan.html +772 -0
  86. package/public/docs/project-teams.html +129 -0
  87. package/public/docs/provider-compatibility.html +20 -0
  88. package/public/docs/releasing.html +65 -0
  89. package/public/docs/remote-workers.html +64 -0
  90. package/public/docs/review-inbox.html +26 -0
  91. package/public/docs/run-storage.html +23 -0
  92. package/public/docs/runs.html +141 -0
  93. package/public/docs/search.json +1 -0
  94. package/public/docs/security.html +133 -0
  95. package/public/docs/shared-projects.html +31 -0
  96. package/public/docs/ssh-projects.html +60 -0
  97. package/public/docs/style.css +768 -0
  98. package/public/docs/task-reruns.html +30 -0
  99. package/public/docs/team-collaboration.html +412 -0
  100. package/public/docs/verification.html +22 -0
  101. package/public/docs/vscode.html +53 -0
  102. package/public/docs/worker-permission-verification.html +131 -0
  103. package/public/index.html +17 -0
  104. package/scripts/check-node.mjs +7 -0
  105. package/scripts/codeflow.mjs +55 -0
  106. package/scripts/computer-use.swift +64 -0
  107. package/scripts/pty.py +273 -0
  108. package/src/acp-connections.js +110 -0
  109. package/src/adapters/acp-presets.js +37 -0
  110. package/src/adapters/acp-types.js +0 -0
  111. package/src/adapters/acp.js +328 -0
  112. package/src/adapters/agent-tools.js +28 -0
  113. package/src/adapters/agy-auth.js +67 -0
  114. package/src/adapters/agy-isolation.js +242 -0
  115. package/src/adapters/agy-tools.js +45 -0
  116. package/src/adapters/agy.js +326 -0
  117. package/src/adapters/claude-stream.js +58 -0
  118. package/src/adapters/claude.js +282 -0
  119. package/src/adapters/codex-app-server.js +555 -0
  120. package/src/adapters/codex.js +311 -0
  121. package/src/adapters/errors.js +57 -0
  122. package/src/adapters/index.js +91 -0
  123. package/src/adapters/managed-permissions.js +37 -0
  124. package/src/adapters/model-discovery.js +100 -0
  125. package/src/adapters/process.js +433 -0
  126. package/src/adapters/team-tools.js +103 -0
  127. package/src/adapters/types.js +0 -0
  128. package/src/agent-browser.js +688 -0
  129. package/src/agent-computer.js +88 -0
  130. package/src/agent-container.js +155 -0
  131. package/src/agent-team-definitions.js +131 -0
  132. package/src/agent-tool-client.mjs +79 -0
  133. package/src/agent-tools.js +296 -0
  134. package/src/approvals.js +134 -0
  135. package/src/architecture.js +456 -0
  136. package/src/assets.js +44 -0
  137. package/src/automation.js +901 -0
  138. package/src/browser-files.js +293 -0
  139. package/src/browser-ownership.js +150 -0
  140. package/src/certificate-renewal.js +75 -0
  141. package/src/chat-family.js +25 -0
  142. package/src/chat-handoff.js +87 -0
  143. package/src/checkpoint-review.js +72 -0
  144. package/src/checks.js +303 -0
  145. package/src/code-host-git.js +102 -0
  146. package/src/code-hosts-api.js +99 -0
  147. package/src/code-hosts.js +482 -0
  148. package/src/code-intelligence-worker.mjs +452 -0
  149. package/src/code-intelligence.js +141 -0
  150. package/src/collab/api.js +365 -0
  151. package/src/collab/checkpoints.js +198 -0
  152. package/src/collab/connections.js +139 -0
  153. package/src/collab/host-execution.js +347 -0
  154. package/src/collab/hub-client.js +210 -0
  155. package/src/collab/link-api.js +229 -0
  156. package/src/collab/link.js +595 -0
  157. package/src/collab/model.js +496 -0
  158. package/src/collab/runner.js +1014 -0
  159. package/src/collab/store.js +1452 -0
  160. package/src/collab/terminals.js +361 -0
  161. package/src/collaboration-reviews.js +116 -0
  162. package/src/config.js +48 -0
  163. package/src/context-budget.js +14 -0
  164. package/src/context-inspection.js +204 -0
  165. package/src/context.js +154 -0
  166. package/src/conventions.js +283 -0
  167. package/src/coordinated-api.js +247 -0
  168. package/src/deferred-launch.js +82 -0
  169. package/src/deferred-runs.js +515 -0
  170. package/src/dependency-health.js +35 -0
  171. package/src/diff.js +43 -0
  172. package/src/docs-help.js +316 -0
  173. package/src/doctor.js +232 -0
  174. package/src/documentation-changes.js +20 -0
  175. package/src/engine.js +5258 -0
  176. package/src/env.js +44 -0
  177. package/src/execution-budget.js +8 -0
  178. package/src/experience.js +50 -0
  179. package/src/extensions.js +144 -0
  180. package/src/filesystem-access.js +107 -0
  181. package/src/filesystem-policy.js +238 -0
  182. package/src/filtered-task-limits.js +93 -0
  183. package/src/filtered-tasks-api.js +53 -0
  184. package/src/filtered-tasks.js +576 -0
  185. package/src/frame-policy.js +66 -0
  186. package/src/fs.js +57 -0
  187. package/src/git.js +626 -0
  188. package/src/github-review-threads.js +181 -0
  189. package/src/github.js +538 -0
  190. package/src/http.js +69 -0
  191. package/src/improve-settings.js +38 -0
  192. package/src/improve.js +187 -0
  193. package/src/integrations.js +373 -0
  194. package/src/integrity.js +259 -0
  195. package/src/journey-api.js +77 -0
  196. package/src/journey-replay.js +65 -0
  197. package/src/journey-store.js +97 -0
  198. package/src/journeys.js +60 -0
  199. package/src/json.js +81 -0
  200. package/src/launch-profiles.js +292 -0
  201. package/src/learnings.js +80 -0
  202. package/src/local-app-url.js +19 -0
  203. package/src/log.js +39 -0
  204. package/src/maintenance.js +335 -0
  205. package/src/memory-analysis.js +182 -0
  206. package/src/memory-claims.js +153 -0
  207. package/src/memory-sqlite.js +236 -0
  208. package/src/memory.js +552 -0
  209. package/src/merge-validation.js +58 -0
  210. package/src/model-catalog.js +239 -0
  211. package/src/model-research.js +91 -0
  212. package/src/notes.js +176 -0
  213. package/src/notifications.js +413 -0
  214. package/src/patterns/auto-team-model.js +230 -0
  215. package/src/patterns/auto-team.js +816 -0
  216. package/src/patterns/council.js +199 -0
  217. package/src/patterns/debate.js +188 -0
  218. package/src/patterns/findings.js +141 -0
  219. package/src/patterns/index.js +21 -0
  220. package/src/patterns/loop.js +158 -0
  221. package/src/patterns/pipeline.js +125 -0
  222. package/src/patterns/prompts.js +181 -0
  223. package/src/patterns/race.js +147 -0
  224. package/src/patterns/schemas.js +40 -0
  225. package/src/patterns/solo.js +46 -0
  226. package/src/patterns/supervisor.js +235 -0
  227. package/src/patterns/task-draft.js +29 -0
  228. package/src/patterns/task-review.js +45 -0
  229. package/src/patterns/tdd.js +231 -0
  230. package/src/patterns/team-strategies.js +67 -0
  231. package/src/performance.js +43 -0
  232. package/src/persistence-status.js +35 -0
  233. package/src/phone-sessions.js +96 -0
  234. package/src/preview-overlay.js +392 -0
  235. package/src/preview.js +230 -0
  236. package/src/privacy.js +23 -0
  237. package/src/project-report.js +22 -0
  238. package/src/project-team-budget.js +22 -0
  239. package/src/project-team-model.js +225 -0
  240. package/src/project-team-policy.js +42 -0
  241. package/src/project-team-store.js +234 -0
  242. package/src/project-team-usage.js +24 -0
  243. package/src/project-teams-api.js +43 -0
  244. package/src/project-teams.js +644 -0
  245. package/src/projects.js +290 -0
  246. package/src/provider-eligibility.js +9 -0
  247. package/src/readiness.js +98 -0
  248. package/src/remote-tls.js +66 -0
  249. package/src/remote-workers/bundles.js +198 -0
  250. package/src/remote-workers/client.js +332 -0
  251. package/src/remote-workers/durability.js +37 -0
  252. package/src/remote-workers/protocol.js +68 -0
  253. package/src/remote-workers/stdio.js +138 -0
  254. package/src/remote-workers/worker.js +304 -0
  255. package/src/remote.js +204 -0
  256. package/src/repo-config.js +201 -0
  257. package/src/repo-coordination.js +725 -0
  258. package/src/repo-tree.js +106 -0
  259. package/src/repository-identity.js +13 -0
  260. package/src/retention-policy.js +466 -0
  261. package/src/review-inbox.js +38 -0
  262. package/src/routing.js +49 -0
  263. package/src/run-analysis.js +111 -0
  264. package/src/run-cold-store.js +231 -0
  265. package/src/run-environment.js +15 -0
  266. package/src/run-history.js +91 -0
  267. package/src/run-log.js +160 -0
  268. package/src/run-permissions.js +111 -0
  269. package/src/run-projection.js +77 -0
  270. package/src/run-recovery.js +53 -0
  271. package/src/run-status.js +56 -0
  272. package/src/serve.js +138 -0
  273. package/src/server.js +2871 -0
  274. package/src/shared-access.js +103 -0
  275. package/src/shared-accounts.js +381 -0
  276. package/src/shared-api.js +239 -0
  277. package/src/shell.js +346 -0
  278. package/src/ship.js +344 -0
  279. package/src/slots.js +74 -0
  280. package/src/snapshot.js +388 -0
  281. package/src/source-context.js +17 -0
  282. package/src/sse-writer.js +70 -0
  283. package/src/ssh-projects/bootstrap.mjs +95 -0
  284. package/src/ssh-projects/config.js +45 -0
  285. package/src/ssh-projects/gateway.js +143 -0
  286. package/src/ssh-projects/manager.js +320 -0
  287. package/src/ssh-projects/team-autostart.mjs +93 -0
  288. package/src/ssh-projects/team-remove.mjs +69 -0
  289. package/src/ssh-projects/team-status.mjs +39 -0
  290. package/src/ssh-projects/transport.js +133 -0
  291. package/src/stats.js +139 -0
  292. package/src/store.js +544 -0
  293. package/src/stream.js +91 -0
  294. package/src/sweeps.js +241 -0
  295. package/src/tailscale.js +101 -0
  296. package/src/task-draft.js +23 -0
  297. package/src/task-team.js +164 -0
  298. package/src/team-evaluation.js +243 -0
  299. package/src/team-mailbox.js +160 -0
  300. package/src/team-memory.js +84 -0
  301. package/src/team-rerun-graph.js +15 -0
  302. package/src/team-reruns.js +130 -0
  303. package/src/team-scheduler.js +121 -0
  304. package/src/team-steering.js +21 -0
  305. package/src/team-template-store.js +98 -0
  306. package/src/team-templates.js +117 -0
  307. package/src/team-tool-client.mjs +79 -0
  308. package/src/team-tools.js +235 -0
  309. package/src/teams.js +0 -0
  310. package/src/templates.js +62 -0
  311. package/src/transcript.js +134 -0
  312. package/src/verification-integrity.js +27 -0
  313. package/src/verification.js +183 -0
  314. package/src/watch.js +195 -0
  315. package/src/work-queue.js +47 -0
  316. package/src/workflow-control.js +83 -0
  317. package/src/workspace-api.js +207 -0
  318. package/src/workspace-changes.js +61 -0
  319. package/src/workspace.js +146 -0
@@ -0,0 +1,576 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { CODEFLOW_HOME } from "./config.js";
7
+ import { childEnv } from "./env.js";
8
+ import { allowsFilePolicy, normalizePolicyPath, validateFilePolicySnapshot } from "./filesystem-policy.js";
9
+ import { sensitiveContextPath } from "./context-inspection.js";
10
+ import { getAdapter } from "./adapters/index.js";
11
+ import { filteredTaskContainer } from "./agent-container.js";
12
+ import { redactExport } from "./privacy.js";
13
+ import { inspectFilteredWorkspace, monitorFilteredWorkspace } from "./filtered-task-limits.js";
14
+ import { durableWorkerDirectory } from "./remote-workers/durability.js";
15
+ const MAX_FILES = 500, MAX_FILE = 512 * 1024, MAX_TOTAL = 16 * 1024 * 1024, MAX_JOBS = 100;
16
+ const sha = (value) => createHash("sha256").update(value).digest("hex");
17
+ const idPattern = /^filtered-[a-f0-9]{24}$/;
18
+ class FilteredTaskError extends Error {
19
+ constructor(message, status = 409) {
20
+ super(message);
21
+ this.status = status;
22
+ }
23
+ }
24
+ const safeEnv = (extra = {}) => {
25
+ const environment = childEnv();
26
+ for (const key of Object.keys(environment)) if (key.startsWith("GIT_")) delete environment[key];
27
+ return { ...environment, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_COUNT: "0", GIT_ATTR_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", ...extra };
28
+ };
29
+ function execute(command, args, options = {}) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = execFile(command, args, { cwd: options.cwd, env: options.env ?? safeEnv(), encoding: "buffer", timeout: options.timeout ?? 3e4, maxBuffer: options.maxBuffer ?? MAX_TOTAL + 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new FilteredTaskError(`${command} failed: ${String(stderr).slice(-2e3) || error.message}`, 422)) : resolve(stdout));
32
+ child.stdin?.on("error", () => void 0);
33
+ child.stdin?.end(options.input);
34
+ });
35
+ }
36
+ async function git(repo, args, options = {}) {
37
+ return execute("git", ["--no-replace-objects", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "core.sparseCheckout=false", "-c", "protocol.allow=never", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "commit.gpgSign=false", ...args], { cwd: repo, ...options, env: safeEnv(options.env) });
38
+ }
39
+ const dockerFilteredRuntime = {
40
+ async pinImage(image) {
41
+ if (!/^[a-zA-Z0-9][\w./:@-]{0,199}$/.test(image)) throw new FilteredTaskError("Choose an explicitly prepared local Docker image", 400);
42
+ if (process.env.DOCKER_HOST && !/^(unix|npipe):\/\//.test(process.env.DOCKER_HOST)) throw new FilteredTaskError("Filtered tasks require a local Docker daemon");
43
+ const endpoint = (await execute("docker", ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"], { timeout: 1e4 })).toString().trim();
44
+ if (!/^(unix|npipe):\/\//.test(endpoint)) throw new FilteredTaskError("Filtered tasks require a local Docker context");
45
+ const id = (await execute("docker", ["image", "inspect", "--format", "{{.Id}}", image], { timeout: 1e4 })).toString().trim();
46
+ if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new FilteredTaskError("Docker did not return an immutable image identity");
47
+ return id;
48
+ },
49
+ async run(job, workspace, signal, onEvent) {
50
+ const container = filteredTaskContainer({ image: job.imageId, network: job.network, memoryMb: 4096, cpus: 2 }, job.id, job.base.filter((file) => !permitted(job.policies, "write", file.path)).map((file) => file.path));
51
+ return getAdapter(job.agent).run({ cwd: workspace, prompt: `This is a filtered committed repository snapshot. Original history, ignored files, denied paths, host credentials and private application memory are absent. Work only in this directory. Changes outside the permitted import paths will be rejected. Do not modify .git.
52
+
53
+ Task:
54
+ ${job.task}`, mode: "write", trust: "safe", signal, timeoutMs: job.maxMinutes * 6e4, agentContainer: container, questionProtocol: "structured", interactiveApprovals: false }, onEvent);
55
+ },
56
+ async stop(jobId) {
57
+ if (!idPattern.test(jobId)) throw new FilteredTaskError("Invalid filtered task identity", 400);
58
+ const listed = (await execute("docker", ["ps", "-aq", "--filter", "label=codeflow.managed-agent=true", "--filter", `label=codeflow.filtered-task=${jobId}`], { timeout: 1e4 })).toString().trim().split(/\s+/).filter(Boolean);
59
+ if (listed.some((id) => !/^[a-f0-9]{12,64}$/.test(id))) throw new FilteredTaskError("Docker returned an ambiguous container receipt");
60
+ if (listed.length) await execute("docker", ["rm", "--force", ...listed], { timeout: 15e3 });
61
+ const remaining = (await execute("docker", ["ps", "-aq", "--filter", `label=codeflow.filtered-task=${jobId}`], { timeout: 1e4 })).toString().trim();
62
+ if (remaining) throw new FilteredTaskError("The filtered task's daemon container could not be confirmed stopped");
63
+ }
64
+ };
65
+ const policyIdentity = (policies) => sha(JSON.stringify(policies.map((policy) => [policy.projectId, policy.revision, policy.hash]).sort()));
66
+ const permitted = (policies, operation, path) => !sensitiveContextPath(path) && policies.every((policy) => allowsFilePolicy(policy, operation, path));
67
+ const resultIdentity = (files) => sha(JSON.stringify(files.map(({ path, hash, mode, bytes }) => ({ path, hash, mode, bytes })).sort((a, b) => a.path.localeCompare(b.path))));
68
+ class FilteredTasks {
69
+ constructor(options) {
70
+ this.options = options;
71
+ this.directory = join(options.home ?? CODEFLOW_HOME, "filtered-tasks");
72
+ this.runtime = options.runtime ?? dockerFilteredRuntime;
73
+ }
74
+ jobs = /* @__PURE__ */ new Map();
75
+ controllers = /* @__PURE__ */ new Map();
76
+ work = /* @__PURE__ */ new Map();
77
+ writes = /* @__PURE__ */ new Map();
78
+ preparations = /* @__PURE__ */ new Map();
79
+ importClaims = /* @__PURE__ */ new Set();
80
+ importWork = /* @__PURE__ */ new Map();
81
+ mutations = /* @__PURE__ */ new Map();
82
+ reservations = 0;
83
+ loaded = false;
84
+ closing = false;
85
+ directory;
86
+ runtime;
87
+ directoryFor(id) {
88
+ if (!idPattern.test(id)) throw new FilteredTaskError("Invalid filtered task", 400);
89
+ return join(this.directory, id);
90
+ }
91
+ workspace(id) {
92
+ return join(this.directoryFor(id), "workspace");
93
+ }
94
+ list(project) {
95
+ return [...this.jobs.values()].filter((job) => !project || job.project === project).sort((a, b) => b.createdAt - a.createdAt).map((job) => this.get(job.id));
96
+ }
97
+ summaries(project) {
98
+ return [...this.jobs.values()].filter((job) => !project || job.project === project).sort((a, b) => b.createdAt - a.createdAt).map(({ id, revision, project: project2, status, createdAt, updatedAt, containerStopped, importBranch, importCommit }) => ({ id, revision, project: project2, status, createdAt, updatedAt, containerStopped, importBranch, importCommit }));
99
+ }
100
+ async read(id) {
101
+ const job = this.require(id);
102
+ const policies = await this.options.authorize(job.project, job.repo);
103
+ if (policyIdentity(policies) !== policyIdentity(job.policies)) throw new FilteredTaskError("Historical task content is unavailable after its filesystem policy changed; metadata and explicit cancellation remain available", 403);
104
+ return this.get(id);
105
+ }
106
+ get(id) {
107
+ const job = this.require(id);
108
+ const { base: _base, result: _result, policies: _policies, ...view } = job;
109
+ return structuredClone({ ...view, exportedFiles: job.base.length, exportedBytes: job.base.reduce((sum, file) => sum + file.bytes, 0), policyRevisions: job.policies.map(({ projectId, revision, hash }) => ({ projectId, revision, hash })) });
110
+ }
111
+ active() {
112
+ return !!this.reservations || !!this.importClaims.size || !!this.mutations.size || [...this.jobs.values()].some((job) => ["preparing", "running", "importing"].includes(job.status) || !job.containerStopped);
113
+ }
114
+ mutation(id, operation) {
115
+ this.require(id);
116
+ if (this.closing || this.mutations.has(id) || this.importClaims.has(id)) throw new FilteredTaskError("Another import or task mutation is already claimed, or the service is closing");
117
+ const work = Promise.resolve().then(operation);
118
+ this.mutations.set(id, work);
119
+ void work.finally(() => {
120
+ if (this.mutations.get(id) === work) this.mutations.delete(id);
121
+ }).catch(() => void 0);
122
+ return work;
123
+ }
124
+ require(id) {
125
+ const job = this.jobs.get(id);
126
+ if (!job) throw new FilteredTaskError("Filtered task not found", 404);
127
+ return job;
128
+ }
129
+ async load() {
130
+ await durableWorkerDirectory(this.directory);
131
+ const info = await lstat(this.directory);
132
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new FilteredTaskError("Filtered task storage is not a private regular directory", 503);
133
+ const storageParent = await open(dirname(this.directory), "r");
134
+ try {
135
+ await storageParent.sync();
136
+ } finally {
137
+ await storageParent.close();
138
+ }
139
+ const entries = await readdir(this.directory);
140
+ if (entries.length > MAX_JOBS) throw new FilteredTaskError("Filtered task history exceeds its bound", 503);
141
+ for (const id of entries) {
142
+ if (!idPattern.test(id)) throw new FilteredTaskError("Unrecognized filtered task storage entry", 503);
143
+ const folder = this.directoryFor(id);
144
+ const metadata = await lstat(folder);
145
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new FilteredTaskError("Symbolic filtered task storage is refused", 503);
146
+ const handle = await open(join(folder, "job.json"), constants.O_RDONLY | constants.O_NOFOLLOW);
147
+ let job;
148
+ try {
149
+ const stat = await handle.stat();
150
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error("Unbounded job record");
151
+ job = JSON.parse(await handle.readFile("utf8"));
152
+ } finally {
153
+ await handle.close();
154
+ }
155
+ if (job.version !== 1 || job.id !== id || !Number.isSafeInteger(job.revision) || !Array.isArray(job.base) || job.base.length > MAX_FILES || !Array.isArray(job.policies) || !Array.isArray(job.events) || job.events.length > 100 || !["preparing", "running", "completed", "failed", "cancelled", "unknown", "importing", "imported"].includes(job.status)) throw new FilteredTaskError("Invalid filtered task metadata; repair private storage", 503);
156
+ job.policies = job.policies.map(validateFilePolicySnapshot);
157
+ const records = (files, baseline) => {
158
+ let bytes = 0;
159
+ const paths = /* @__PURE__ */ new Set();
160
+ if (!Array.isArray(files) || files.length > MAX_FILES) throw new FilteredTaskError("Unbounded filtered task manifest", 503);
161
+ for (const file of files) {
162
+ if (!file || normalizePolicyPath(file.path) !== file.path || sensitiveContextPath(file.path) || paths.has(file.path) || !/^[a-f0-9]{64}$/.test(file.hash) || !["100644", "100755"].includes(file.mode) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > MAX_FILE || (bytes += file.bytes) > MAX_TOTAL || baseline && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(file.blob ?? "")) throw new FilteredTaskError("Invalid filtered task manifest", 503);
163
+ paths.add(file.path);
164
+ }
165
+ };
166
+ records(job.base, true);
167
+ if (job.result) records(job.result, false);
168
+ if (job.sourceHead && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(job.sourceHead) || job.importBranch && job.importBranch !== `codeflow/${id}` || job.importCommit && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(job.importCommit)) throw new FilteredTaskError("Invalid filtered task source/import identity", 503);
169
+ this.jobs.set(id, job);
170
+ if (["preparing", "running", "importing"].includes(job.status)) await this.save(job, { status: "unknown", error: "Application restarted during an effectful phase. Inspect an import receipt or cancel and confirm the owned Docker container stopped; this task will not retry automatically." });
171
+ }
172
+ this.loaded = true;
173
+ }
174
+ async save(job, patch) {
175
+ const work = (this.writes.get(job.id) ?? Promise.resolve()).catch(() => void 0).then(async () => {
176
+ const next = { ...job, ...patch, revision: job.revision + 1, updatedAt: Date.now() }, folder = this.directoryFor(job.id), file = join(folder, "job.json"), temp = join(folder, `.job-${randomBytes(6).toString("hex")}.tmp`);
177
+ const handle = await open(temp, "wx", 384);
178
+ try {
179
+ await handle.writeFile(JSON.stringify(next));
180
+ await handle.sync();
181
+ } finally {
182
+ await handle.close();
183
+ }
184
+ try {
185
+ await rename(temp, file);
186
+ const directory = await open(folder, "r");
187
+ try {
188
+ await directory.sync();
189
+ } finally {
190
+ await directory.close();
191
+ }
192
+ Object.assign(job, next);
193
+ } finally {
194
+ await rm(temp, { force: true }).catch(() => void 0);
195
+ }
196
+ });
197
+ this.writes.set(job.id, work);
198
+ try {
199
+ await work;
200
+ } finally {
201
+ if (this.writes.get(job.id) === work) this.writes.delete(job.id);
202
+ }
203
+ }
204
+ async current(job) {
205
+ const policies = await this.options.authorize(job.project, job.repo);
206
+ if (policyIdentity(policies) !== policyIdentity(job.policies)) throw new FilteredTaskError("Filesystem policy revision or project membership changed; prepare a new filtered task");
207
+ job.policies = structuredClone(policies);
208
+ if ((await git(job.repo, ["rev-parse", "--verify", "HEAD^{commit}"])).toString().trim() !== job.sourceHead) throw new FilteredTaskError("Original source revision changed; prepare a new filtered task");
209
+ const source = (await git(job.repo, ["ls-tree", "-rz", "-l", "--full-tree", job.sourceHead], { maxBuffer: 8 * 1024 * 1024 })).toString("utf8").split("\0").filter(Boolean);
210
+ const records = new Map(source.map((entry) => {
211
+ const match = /^(\d{6}) (blob|commit) ([a-f0-9]+)\s+(\d+|-)\t([\s\S]+)$/.exec(entry);
212
+ if (!match) throw new FilteredTaskError("Source tree contains an unsupported record");
213
+ return [match[5], { mode: match[1], blob: match[3], bytes: Number(match[4]) }];
214
+ }));
215
+ for (const file of job.base) {
216
+ const original = records.get(file.path);
217
+ if (!permitted(policies, "read", file.path) || !original || original.blob !== file.blob || original.mode !== file.mode || original.bytes !== file.bytes) throw new FilteredTaskError("Stored export manifest does not match the authorized source revision");
218
+ }
219
+ }
220
+ async create(raw) {
221
+ if (!this.loaded || this.closing) throw new FilteredTaskError("Filtered task service is unavailable or closing", 503);
222
+ if (this.jobs.size + this.reservations >= MAX_JOBS || this.reservations || this.active()) throw new FilteredTaskError("Wait for the current filtered task; history is bounded to 100 tasks");
223
+ if (typeof raw.project !== "string" || typeof raw.repo !== "string" || !raw.repo.startsWith("/") || typeof raw.task !== "string" || !raw.task.trim() || Buffer.byteLength(raw.task) > 24 * 1024 || !["claude", "codex", "agy"].includes(String(raw.agent)) || typeof raw.image !== "string" || raw.network !== void 0 && typeof raw.network !== "boolean" || raw.maxMinutes !== void 0 && (!Number.isInteger(raw.maxMinutes) || raw.maxMinutes < 1 || raw.maxMinutes > 30)) throw new FilteredTaskError("Choose a project, repository, task, supported provider, prepared image and 1\u201330 minute limit", 400);
224
+ this.reservations++;
225
+ const id = `filtered-${randomBytes(12).toString("hex")}`, folder = this.directoryFor(id);
226
+ let repo = raw.repo;
227
+ const controller = new AbortController();
228
+ this.controllers.set(id, controller);
229
+ let preparationDone;
230
+ this.preparations.set(id, new Promise((resolve) => {
231
+ preparationDone = resolve;
232
+ }));
233
+ const checkPreparation = () => {
234
+ if (this.closing || controller.signal.aborted) throw new FilteredTaskError("Filtered task preparation cancelled before provider dispatch");
235
+ };
236
+ const job = { version: 1, id, revision: 0, project: raw.project, repo, task: raw.task.trim(), agent: raw.agent, image: raw.image, network: raw.network === true, maxMinutes: raw.maxMinutes ?? 10, status: "preparing", createdAt: Date.now(), updatedAt: Date.now(), policies: [], base: [], events: [], containerStopped: true };
237
+ try {
238
+ checkPreparation();
239
+ repo = await realpath(repo);
240
+ job.repo = repo;
241
+ job.policies = await this.options.authorize(job.project, repo);
242
+ if (!job.policies.length) throw new FilteredTaskError("Assign this repository to the selected project before preparing a filtered task");
243
+ await mkdir(folder, { mode: 448 });
244
+ const directory = await open(this.directory, "r");
245
+ try {
246
+ await directory.sync();
247
+ } finally {
248
+ await directory.close();
249
+ }
250
+ this.jobs.set(id, job);
251
+ await this.save(job, {});
252
+ await this.options.withRepository(repo, async () => {
253
+ checkPreparation();
254
+ job.sourceHead = (await git(repo, ["rev-parse", "--verify", "HEAD^{commit}"])).toString().trim();
255
+ job.imageId = await this.runtime.pinImage(job.image);
256
+ checkPreparation();
257
+ if (!/^sha256:[a-f0-9]{64}$/.test(job.imageId)) throw new FilteredTaskError("Runtime image identity is not immutable");
258
+ await mkdir(this.workspace(id), { mode: 448 });
259
+ const tree = (await git(repo, ["ls-tree", "-rz", "-l", "--full-tree", job.sourceHead], { maxBuffer: 8 * 1024 * 1024 })).toString("utf8").split("\0").filter(Boolean);
260
+ let bytes = 0;
261
+ for (const entry of tree) {
262
+ checkPreparation();
263
+ const match = /^(\d{6}) (blob|commit) ([a-f0-9]+)\s+(\d+|-)\t([\s\S]+)$/.exec(entry);
264
+ if (!match) throw new FilteredTaskError("Source tree contains an unsupported record");
265
+ const path = normalizePolicyPath(match[5]);
266
+ if (!permitted(job.policies, "read", path)) continue;
267
+ if (!["100644", "100755"].includes(match[1]) || match[2] !== "blob") throw new FilteredTaskError("Readable symlinks and Gitlinks are unsupported in a filtered snapshot");
268
+ const size = Number(match[4]);
269
+ if (job.base.length >= MAX_FILES || size > MAX_FILE || (bytes += size) > MAX_TOTAL) throw new FilteredTaskError("Filtered source exceeds 500 files, 512 KB per file or 16 MB total");
270
+ const contents = await git(repo, ["cat-file", "blob", match[3]], { maxBuffer: MAX_FILE + 1 });
271
+ if (contents.length !== size) throw new FilteredTaskError("Source blob size changed");
272
+ const target = join(this.workspace(id), path);
273
+ await mkdir(dirname(target), { recursive: true, mode: 448 });
274
+ const handle = await open(target, "wx", match[1] === "100755" ? 448 : 384);
275
+ try {
276
+ await handle.writeFile(contents);
277
+ } finally {
278
+ await handle.close();
279
+ }
280
+ job.base.push({ path, hash: sha(contents), mode: match[1], bytes: size, blob: match[3] });
281
+ }
282
+ await git(this.workspace(id), ["init", "-q", "--template=", "-b", "main"]);
283
+ await git(this.workspace(id), ["add", "--force", "-A", "--", "."]);
284
+ await git(this.workspace(id), ["-c", "user.name=code-flow filtered snapshot", "-c", "user.email=filtered@localhost", "commit", "--allow-empty", "-qm", "Filtered committed snapshot"]);
285
+ await this.current(job);
286
+ checkPreparation();
287
+ await this.save(job, { sourceHead: job.sourceHead, imageId: job.imageId, base: job.base, policies: job.policies, status: "running", containerStopped: false });
288
+ checkPreparation();
289
+ });
290
+ checkPreparation();
291
+ const work = this.launch(job, controller);
292
+ this.work.set(id, work);
293
+ void work.finally(() => {
294
+ this.controllers.delete(id);
295
+ this.work.delete(id);
296
+ }).catch(() => void 0);
297
+ return this.get(id);
298
+ } catch (error) {
299
+ if (this.jobs.has(id)) await this.save(job, { status: controller.signal.aborted || this.closing ? "cancelled" : "failed", error: error.message, containerStopped: true });
300
+ throw error;
301
+ } finally {
302
+ this.reservations--;
303
+ preparationDone();
304
+ this.preparations.delete(id);
305
+ if (!this.work.has(id)) this.controllers.delete(id);
306
+ }
307
+ }
308
+ async scan(job) {
309
+ const root = await lstat(this.workspace(job.id));
310
+ if (!root.isDirectory() || root.isSymbolicLink()) throw new FilteredTaskError("Filtered export root must be a regular private directory");
311
+ await inspectFilteredWorkspace(this.workspace(job.id));
312
+ const files = [];
313
+ let bytes = 0;
314
+ const walk = async (relative, depth) => {
315
+ if (depth > 30) throw new FilteredTaskError("Result directory depth exceeds its bound");
316
+ const folder = join(this.workspace(job.id), relative);
317
+ const entries = await readdir(folder, { withFileTypes: true });
318
+ if (entries.length > 1e3) throw new FilteredTaskError("Result directory exceeds its entry bound");
319
+ for (const entry of entries) {
320
+ const path = normalizePolicyPath(relative ? `${relative}/${entry.name}` : entry.name);
321
+ const absolute = join(this.workspace(job.id), path), info = await lstat(absolute);
322
+ if (info.isSymbolicLink()) throw new FilteredTaskError("Result symlinks are refused");
323
+ if (info.isDirectory()) {
324
+ if (sensitiveContextPath(path) || !job.policies.every((policy) => allowsFilePolicy(policy, "read", path) || policy.read.allow.some((root2) => root2.startsWith(`${path}/`)) && !policy.read.deny.some((root2) => path === root2 || path.startsWith(`${root2}/`)))) throw new FilteredTaskError("Result contains a forbidden directory");
325
+ await walk(path, depth + 1);
326
+ continue;
327
+ }
328
+ if (!permitted(job.policies, "read", path)) throw new FilteredTaskError("Result contains a forbidden path; no changes will be imported");
329
+ if (!info.isFile() || info.nlink !== 1 || info.size > MAX_FILE || files.length >= MAX_FILES || (bytes += info.size) > MAX_TOTAL) throw new FilteredTaskError("Result contains a special/hard-linked file or exceeds its bounded file count/size");
330
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
331
+ let content;
332
+ try {
333
+ content = await handle.readFile();
334
+ } finally {
335
+ await handle.close();
336
+ }
337
+ if (content.length !== info.size || content.length > MAX_FILE) throw new FilteredTaskError("Result file changed during validation");
338
+ files.push({ path, hash: sha(content), mode: info.mode & 73 ? "100755" : "100644", bytes: content.length });
339
+ }
340
+ };
341
+ await walk("", 0);
342
+ for (const path of new Set([...job.base, ...files].map((file) => file.path))) {
343
+ const before = job.base.find((file) => file.path === path), after = files.find((file) => file.path === path);
344
+ if ((!before || !after || before.hash !== after.hash || before.mode !== after.mode) && !permitted(job.policies, "write", path)) throw new FilteredTaskError("Result modified a path outside the write policy; nothing will be imported");
345
+ }
346
+ return files.sort((a, b) => a.path.localeCompare(b.path));
347
+ }
348
+ async launch(job, controller) {
349
+ try {
350
+ await this.current(job);
351
+ if (await this.runtime.pinImage(job.imageId) !== job.imageId) throw new FilteredTaskError("Pinned Docker image changed");
352
+ if (controller.signal.aborted || this.closing) throw new FilteredTaskError("Filtered task cancelled before provider dispatch");
353
+ await this.current(job);
354
+ if (controller.signal.aborted || this.closing) throw new FilteredTaskError("Filtered task cancelled before provider dispatch");
355
+ let exceeded;
356
+ const monitor = monitorFilteredWorkspace(this.workspace(job.id), { signal: controller.signal, onExceeded: (error) => {
357
+ exceeded = error;
358
+ controller.abort();
359
+ } });
360
+ let result;
361
+ try {
362
+ result = await this.runtime.run(structuredClone(job), this.workspace(job.id), controller.signal, (event) => {
363
+ const text = event.kind === "status" || event.kind === "text" ? event.text : event.kind === "error" ? event.message : void 0;
364
+ if (text) job.events = [...job.events, { at: Date.now(), text: String(redactExport(text)).slice(0, 1e3) }].slice(-100);
365
+ });
366
+ } finally {
367
+ await monitor.stop();
368
+ }
369
+ if (exceeded) throw exceeded;
370
+ await this.runtime.stop(job.id);
371
+ await this.save(job, { containerStopped: true });
372
+ if (controller.signal.aborted) {
373
+ await this.save(job, { status: "cancelled", error: "Cancelled after confirming the daemon container stopped" });
374
+ return;
375
+ }
376
+ await rm(join(this.workspace(job.id), ".git"), { recursive: true, force: true });
377
+ await this.current(job);
378
+ if (!result.ok) throw new FilteredTaskError(result.error || "Provider turn failed");
379
+ const files = await this.scan(job);
380
+ await this.save(job, { status: "completed", result: files, resultHash: resultIdentity(files), finalText: String(redactExport(result.finalText)).slice(0, 32 * 1024) });
381
+ } catch (error) {
382
+ let stopped = false;
383
+ try {
384
+ await this.runtime.stop(job.id);
385
+ stopped = true;
386
+ } catch {
387
+ }
388
+ await this.save(job, { status: stopped ? controller.signal.aborted ? "cancelled" : "failed" : "unknown", containerStopped: stopped, error: error.message });
389
+ }
390
+ }
391
+ async cancel(id, revision) {
392
+ return this.mutation(id, async () => {
393
+ const job = this.require(id);
394
+ if (revision !== job.revision) throw new FilteredTaskError("Filtered task changed; refresh its current state");
395
+ if (this.importClaims.has(id) || job.status === "importing" || job.importCommit && job.status === "unknown") throw new FilteredTaskError("Inspect the interrupted import receipt before changing this job");
396
+ this.controllers.get(id)?.abort();
397
+ await this.preparations.get(id);
398
+ await this.work.get(id);
399
+ await this.runtime.stop(id);
400
+ await this.save(job, { status: "cancelled", containerStopped: true, error: "Owner cancelled; daemon container absence confirmed" });
401
+ return this.get(id);
402
+ });
403
+ }
404
+ async preview(id) {
405
+ const job = this.require(id);
406
+ if (job.status !== "completed") throw new FilteredTaskError("Only a completed validated task has an import preview");
407
+ await this.current(job);
408
+ const files = await this.scan(job);
409
+ if (resultIdentity(files) !== job.resultHash) throw new FilteredTaskError("Result changed after completion; import is refused");
410
+ const changes = [];
411
+ let previewBytes = 0;
412
+ for (const path of [...new Set([...job.base, ...files].map((file) => file.path))].sort()) {
413
+ const before = job.base.find((file) => file.path === path), after = files.find((file) => file.path === path);
414
+ if (before?.hash === after?.hash && before?.mode === after?.mode) continue;
415
+ const change = { path, status: !before ? "added" : !after ? "deleted" : "modified", beforeHash: before?.hash, afterHash: after?.hash, mode: after?.mode, bytes: after?.bytes ?? 0 };
416
+ const source = before ? await git(job.repo, ["cat-file", "blob", before.blob], { maxBuffer: MAX_FILE + 1 }) : Buffer.alloc(0), target = after ? await this.content(job, after) : Buffer.alloc(0);
417
+ change.binary = source.includes(0) || target.includes(0);
418
+ if (!change.binary && previewBytes < 512 * 1024) {
419
+ change.before = source.toString("utf8").slice(0, 2e4);
420
+ change.after = target.toString("utf8").slice(0, 2e4);
421
+ previewBytes += Buffer.byteLength(change.before) + Buffer.byteLength(change.after);
422
+ change.truncated = source.length > 2e4 || target.length > 2e4;
423
+ } else change.truncated = true;
424
+ changes.push(change);
425
+ }
426
+ return { job: this.get(id), sourceHead: job.sourceHead, resultHash: job.resultHash, changes, importBehavior: "Creates a new review branch; original checkout and index remain unchanged" };
427
+ }
428
+ async content(job, file) {
429
+ const handle = await open(join(this.workspace(job.id), file.path), constants.O_RDONLY | constants.O_NOFOLLOW);
430
+ try {
431
+ const bytes = await handle.readFile();
432
+ if (bytes.length > MAX_FILE || sha(bytes) !== file.hash) throw new FilteredTaskError("Result changed before import");
433
+ return bytes;
434
+ } finally {
435
+ await handle.close();
436
+ }
437
+ }
438
+ async import(id, input) {
439
+ const job = this.require(id);
440
+ if (job.status !== "completed" || input.revision !== job.revision || input.expectedHead !== job.sourceHead || input.resultHash !== job.resultHash) throw new FilteredTaskError("Refresh and review the exact current completed result before import");
441
+ if (this.closing || this.importClaims.has(id) || this.mutations.has(id)) throw new FilteredTaskError("An import or task mutation is already claimed or the service is closing");
442
+ this.importClaims.add(id);
443
+ const work = this.importClaimed(job);
444
+ this.importWork.set(id, work);
445
+ void work.finally(() => {
446
+ this.importClaims.delete(id);
447
+ this.importWork.delete(id);
448
+ }).catch(() => void 0);
449
+ return work;
450
+ }
451
+ async importClaimed(job) {
452
+ const id = job.id;
453
+ return this.options.withRepository(job.repo, async () => {
454
+ if (this.closing) throw new FilteredTaskError("Import stopped before publication");
455
+ await this.current(job);
456
+ const preview = await this.preview(id);
457
+ if (!preview.changes.length) throw new FilteredTaskError("The filtered task has no changes to import");
458
+ const assertOriginalFiles = async () => {
459
+ const staged = (await git(job.repo, ["ls-files", "--stage", "-z"])).toString().split("\0").filter(Boolean);
460
+ const index = new Map(staged.map((entry) => {
461
+ const match = /^(\d+) ([a-f0-9]+) (\d)\t([\s\S]+)$/.exec(entry);
462
+ if (!match || match[3] !== "0") throw new FilteredTaskError("Resolve the original index's conflicts before importing");
463
+ return [match[4], { mode: match[1], blob: match[2] }];
464
+ }));
465
+ for (const change of preview.changes) {
466
+ const baseline = job.base.find((file) => file.path === change.path);
467
+ let currentPath = job.repo;
468
+ for (const component of change.path.split("/")) {
469
+ currentPath = join(currentPath, component);
470
+ const entry = await lstat(currentPath).catch((error) => {
471
+ if (error.code === "ENOENT") return void 0;
472
+ throw error;
473
+ });
474
+ if (entry?.isSymbolicLink()) throw new FilteredTaskError("Original source path became symbolic; import refused");
475
+ }
476
+ const info = await lstat(currentPath).catch((error) => {
477
+ if (error.code === "ENOENT") return void 0;
478
+ throw error;
479
+ });
480
+ if (!baseline) {
481
+ if (info || index.has(change.path)) throw new FilteredTaskError("A new result path already exists in the original checkout/index");
482
+ } else {
483
+ if (!info?.isFile() || info.size > MAX_FILE || index.get(change.path)?.blob !== baseline.blob || index.get(change.path)?.mode !== baseline.mode || (info.mode & 73 ? "100755" : "100644") !== baseline.mode) throw new FilteredTaskError("Original file mode/index changed; import refused");
484
+ const file = await open(currentPath, constants.O_RDONLY | constants.O_NOFOLLOW);
485
+ try {
486
+ if (sha(await file.readFile()) !== baseline.hash) throw new FilteredTaskError("Original file contents changed; import refused");
487
+ } finally {
488
+ await file.close();
489
+ }
490
+ }
491
+ }
492
+ };
493
+ await assertOriginalFiles();
494
+ const temporaryIndex = join(this.directoryFor(id), `index-${randomBytes(6).toString("hex")}`), env = { GIT_INDEX_FILE: temporaryIndex };
495
+ let commit;
496
+ try {
497
+ await git(job.repo, ["read-tree", job.sourceHead], { env });
498
+ for (const change of preview.changes) {
499
+ const file = job.result.find((file2) => file2.path === change.path);
500
+ if (!file) await git(job.repo, ["update-index", "--force-remove", "--", change.path], { env });
501
+ else {
502
+ const blob = (await git(job.repo, ["hash-object", "--no-filters", "-w", "--stdin"], { input: await this.content(job, file), env })).toString().trim();
503
+ await git(job.repo, ["update-index", "--add", "--cacheinfo", file.mode, blob, file.path], { env });
504
+ }
505
+ }
506
+ const tree = (await git(job.repo, ["write-tree"], { env })).toString().trim();
507
+ commit = (await git(job.repo, ["-c", "user.name=code-flow filtered task", "-c", "user.email=filtered@localhost", "commit-tree", tree, "-p", job.sourceHead], { input: `Filtered task ${job.id}
508
+ `, env })).toString().trim();
509
+ } finally {
510
+ await rm(temporaryIndex, { force: true });
511
+ await rm(`${temporaryIndex}.lock`, { force: true });
512
+ }
513
+ await this.current(job);
514
+ if (resultIdentity(await this.scan(job)) !== job.resultHash) throw new FilteredTaskError("Result changed during import preparation");
515
+ const branch = `codeflow/${job.id}`;
516
+ if (this.closing) throw new FilteredTaskError("Import stopped before publication");
517
+ await this.save(job, { status: "importing", importBranch: branch, importCommit: commit });
518
+ try {
519
+ if (this.closing) throw new FilteredTaskError("Import stopped before publication");
520
+ await this.current(job);
521
+ await assertOriginalFiles();
522
+ if (this.closing) throw new FilteredTaskError("Import stopped before publication");
523
+ await git(job.repo, ["update-ref", "--stdin"], { input: `start
524
+ verify HEAD ${job.sourceHead}
525
+ create refs/heads/${branch} ${commit}
526
+ prepare
527
+ commit
528
+ ` });
529
+ await this.save(job, { status: "imported" });
530
+ } catch (error) {
531
+ await this.save(job, { status: "unknown", error: "Import receipt is ambiguous. Inspect it explicitly; no automatic retry is allowed." });
532
+ throw error;
533
+ }
534
+ return this.get(id);
535
+ });
536
+ }
537
+ async reconcile(id, revision) {
538
+ return this.mutation(id, async () => {
539
+ const job = this.require(id);
540
+ if (job.status !== "unknown" || revision !== job.revision || !job.importCommit || !job.importBranch) throw new FilteredTaskError("There is no matching interrupted import receipt");
541
+ await this.options.authorize(job.project, job.repo);
542
+ const ref = await git(job.repo, ["show-ref", "--verify", "--hash", `refs/heads/${job.importBranch}`]).then((buffer) => buffer.toString().trim(), () => "");
543
+ if (ref && ref !== job.importCommit) throw new FilteredTaskError("The review branch no longer matches the saved import receipt");
544
+ await this.save(job, { status: ref ? "imported" : "completed", error: ref ? void 0 : "The atomic branch transaction did not publish a ref; review again before an explicit retry.", ...ref ? {} : { importBranch: void 0, importCommit: void 0 } });
545
+ return this.get(id);
546
+ });
547
+ }
548
+ async delete(id, revision) {
549
+ return this.mutation(id, async () => {
550
+ const job = this.require(id);
551
+ if (revision !== job.revision || this.importClaims.has(id) || this.preparations.has(id) || this.work.has(id) || !job.containerStopped || ["preparing", "running", "importing", "unknown"].includes(job.status)) throw new FilteredTaskError("Only a terminal task with a confirmed stopped container and no ambiguous import may be deleted");
552
+ await this.writes.get(id);
553
+ await rm(this.directoryFor(id), { recursive: true, force: false });
554
+ const directory = await open(this.directory, "r");
555
+ try {
556
+ await directory.sync();
557
+ } finally {
558
+ await directory.close();
559
+ }
560
+ this.jobs.delete(id);
561
+ return { deleted: true, preservedReviewBranch: job.importBranch };
562
+ });
563
+ }
564
+ async close() {
565
+ this.closing = true;
566
+ for (const controller of this.controllers.values()) controller.abort();
567
+ await Promise.allSettled([...this.preparations.values()]);
568
+ await Promise.allSettled([...this.work.values(), ...this.importWork.values(), ...this.mutations.values()]);
569
+ await Promise.allSettled([...this.writes.values()]);
570
+ }
571
+ }
572
+ export {
573
+ FilteredTaskError,
574
+ FilteredTasks,
575
+ dockerFilteredRuntime
576
+ };
@@ -0,0 +1,66 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+ import { addressKind, snapshotUrl } from "./snapshot.js";
6
+ function embeddingBlocked(headers, page, parent) {
7
+ const origin = new URL(page).origin, parentUrl = new URL(parent);
8
+ const policies = [headers["content-security-policy"] ?? []].flat().flatMap((policy) => policy.split(","));
9
+ const ancestors = policies.flatMap((policy) => {
10
+ const directive = policy.split(";").map((value) => value.trim()).find((value) => /^frame-ancestors(?:\s|$)/i.test(value));
11
+ return directive ? [directive] : [];
12
+ });
13
+ const allows = (source) => {
14
+ if (source === "*") return true;
15
+ if (source === "'self'") return origin === parentUrl.origin;
16
+ if (/^https?:$/.test(source)) return parentUrl.protocol === source;
17
+ try {
18
+ const candidate = new URL(source.includes("://") ? source.replace(/:\*(?=\/|$)/, ":65535") : `${new URL(page).protocol}//${source}`);
19
+ const host = candidate.hostname;
20
+ return candidate.protocol === parentUrl.protocol && (host === parentUrl.hostname || host.startsWith("*.") && parentUrl.hostname.endsWith(host.slice(1))) && (/:\*(?:\/|$)/.test(source) || candidate.port === parentUrl.port);
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+ if (ancestors.length) return ancestors.some((policy) => !policy.split(/\s+/).slice(1).some(allows)) ? "Content-Security-Policy frame-ancestors" : void 0;
26
+ const frame = [headers["x-frame-options"] ?? []].flat().flatMap((value) => value.toUpperCase().split(",")).map((value) => value.trim());
27
+ const restriction = frame.find((value) => value === "DENY" || value === "SAMEORIGIN" && origin !== parentUrl.origin);
28
+ return restriction ? `X-Frame-Options: ${restriction}` : void 0;
29
+ }
30
+ async function inspectFramePolicy(address, parent) {
31
+ let url = new URL(snapshotUrl(address));
32
+ for (let redirects = 0; redirects <= 5; redirects++) {
33
+ if (url.username || url.password) throw new Error("Page inspection does not accept credentials in URLs");
34
+ const host = url.hostname.replace(/^\[|\]$/g, "");
35
+ const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await lookup(host, { all: true });
36
+ if (!addresses.length || addresses.some((value) => addressKind(value.address))) throw new Error("External page inspection cannot reach loopback or link-local addresses");
37
+ const destination = addresses.find((value) => value.family === 4) ?? addresses[0];
38
+ const response = await new Promise((resolve, reject) => {
39
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
40
+ method: "HEAD",
41
+ agent: false,
42
+ family: destination.family,
43
+ signal: AbortSignal.timeout(5e3),
44
+ maxHeaderSize: 32 * 1024,
45
+ // Pin the checked DNS answer so a second resolution cannot redirect the request locally.
46
+ lookup: (_hostname, _options, callback) => callback(null, destination.address, destination.family)
47
+ }, (result) => {
48
+ result.destroy();
49
+ resolve({ status: result.statusCode ?? 0, headers: result.headers });
50
+ });
51
+ request.on("error", reject);
52
+ request.end();
53
+ });
54
+ if ([301, 302, 303, 307, 308].includes(response.status) && response.headers.location) {
55
+ url = new URL(snapshotUrl(new URL(response.headers.location, url).href));
56
+ continue;
57
+ }
58
+ const reason = embeddingBlocked(response.headers, url.href, parent);
59
+ return { blocked: !!reason, reason, url: url.href };
60
+ }
61
+ throw new Error("The external page redirected too many times");
62
+ }
63
+ export {
64
+ embeddingBlocked,
65
+ inspectFramePolicy
66
+ };