dev-orchestrator-connector 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # Dev Orchestrator Connector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The connector runs on your computer and uses your existing local Codex sign-in.
4
+ It downloads only repositories authorized for the paired executor. The cloud
5
+ service coordinates tasks but never hosts your source checkout.
6
+
7
+ Requirements: Node.js 22.18 or newer and the official Codex CLI signed in.
8
+
9
+ Pair this machine using the one-time code shown in **Executors**:
10
+
11
+ ```bash
12
+ mkdir -p "$HOME/.local/share/dev-orchestrator"
13
+ npx --yes dev-orchestrator-connector@0.1.0 pair \
14
+ --server https://YOUR-ORCHESTRATOR-HOST \
15
+ --code YOUR_ONE_USE_CODE \
16
+ --config "$HOME/.local/share/dev-orchestrator/connector.json"
17
+ ```
18
+
19
+ An owner must bind the executor to a project in the web app. Then start it with:
20
+
21
+ ```bash
22
+ npx --yes dev-orchestrator-connector@0.1.0 run \
23
+ --config "$HOME/.local/share/dev-orchestrator/connector.json"
24
+ ```
25
+
26
+ Keep that terminal open while the executor should process tasks. Press `Ctrl+C`
27
+ to stop it safely. The connector stores its credential and runtime data under
28
+ `~/.local/share/dev-orchestrator` with owner-only permissions.
29
+
30
+ This package contains only the local connector. It does not include the cloud
31
+ control plane or provider credentials.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
7
+ const child = spawn(process.execPath, ["--experimental-strip-types", cli, ...process.argv.slice(2)], {
8
+ stdio: "inherit",
9
+ });
10
+
11
+ child.on("error", (error) => {
12
+ console.error(`Could not start the connector: ${error.message}`);
13
+ process.exitCode = 1;
14
+ });
15
+
16
+ child.on("exit", (code, signal) => {
17
+ process.exitCode = code ?? (signal ? 1 : 0);
18
+ });
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "dev-orchestrator-connector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Run a customer-owned local Codex executor for Dev Orchestrator.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dev-orchestrator-connector": "bin/dev-orchestrator-connector.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "engines": { "node": ">=22.18.0" },
14
+ "scripts": {
15
+ "test": "node --experimental-strip-types --test test/*.test.ts",
16
+ "check": "tsc --noEmit",
17
+ "start": "node --experimental-strip-types src/cli.ts"
18
+ },
19
+ "devDependencies": { "@types/node": "22.18.8", "typescript": "5.9.3" }
20
+ }
@@ -0,0 +1,33 @@
1
+ export type ActivityStage =
2
+ | "agent_started"
3
+ | "working"
4
+ | "editing"
5
+ | "validating"
6
+ | "checks_passed";
7
+
8
+ const validationCommand = /\b(test|tests|spec|lint|format|typecheck|type-check|check|build|compile)\b/i;
9
+
10
+ export function activityForNotification(message: any): ActivityStage | undefined {
11
+ if (!message || typeof message !== "object" || !message.params) return undefined;
12
+
13
+ if (message.method === "turn/started") return "agent_started";
14
+
15
+ const item = message.params.item;
16
+ if (message.method === "item/started") {
17
+ if (item?.type === "fileChange") return "editing";
18
+ if (item?.type === "commandExecution") {
19
+ return validationCommand.test(String(item.command ?? "")) ? "validating" : "working";
20
+ }
21
+ }
22
+
23
+ if (
24
+ message.method === "item/completed" &&
25
+ item?.type === "commandExecution" &&
26
+ validationCommand.test(String(item.command ?? "")) &&
27
+ item.exitCode === 0
28
+ ) {
29
+ return "checks_passed";
30
+ }
31
+
32
+ return undefined;
33
+ }
package/src/api.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { serverURL } from "./config.ts";
2
+ export class APIError extends Error {
3
+ readonly status: number;
4
+ readonly code: string;
5
+ constructor(status: number, code: string) { super(`Control plane request failed (${status}: ${code}).`); this.status=status; this.code=code; }
6
+ }
7
+
8
+ export class API {
9
+ readonly origin: string;
10
+ readonly token: string | undefined;
11
+ constructor(origin: string, token?: string) { this.origin=serverURL(origin); this.token=token; }
12
+ async post(path: string, data: unknown): Promise<any> {
13
+ if (!/^\/[a-z/-]+$/.test(path)) throw new Error("Invalid control-plane route.");
14
+ const response = await fetch(this.origin + "/api/v1" + path, {
15
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
16
+ headers: { "content-type":"application/json", ...(this.token ? {authorization:`Bearer ${this.token}`} : {}) },
17
+ body: JSON.stringify(data)
18
+ });
19
+ const body = await response.json() as Record<string,unknown>;
20
+ if (!response.ok) throw new APIError(response.status, typeof body.error === "string" && /^[a-z_]+$/.test(body.error) ? body.error : "request_failed");
21
+ return path === "/enroll" ? body : body.data;
22
+ }
23
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { hostname } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, open, unlink } from "node:fs/promises";
4
+ import { pathToFileURL } from "node:url";
5
+ import { API } from "./api.ts";
6
+ import { loadConfig, saveConfig, serverURL } from "./config.ts";
7
+ import { CodexSession } from "./codex.ts";
8
+ import { runLoop } from "./worker.ts";
9
+
10
+ export async function main(args:string[]):Promise<void> {
11
+ const [action,...rest]=args;
12
+ if(!action || action==="help" || action==="--help") {
13
+ console.log("Dev Orchestrator connector\n\npair --server URL --code CODE --config /absolute/config.json\nstatus --config FILE\nrun --config FILE\n\nProjects and repository access are managed in the web app. This connector uses your official local Codex login and never modifies global provider/Git configuration.");return;
14
+ }
15
+ const opts:Record<string,string>={};
16
+ for(let i=0;i<rest.length;i+=2){if(!/^--[a-z-]+$/.test(rest[i]) || !rest[i+1])throw new Error("Options require --name value pairs.");opts[rest[i].slice(2)]=rest[i+1];}
17
+ if(!opts.config || !opts.config.startsWith("/"))throw new Error("Provide an absolute --config path.");
18
+ const file=opts.config;
19
+ if(action==="pair") {
20
+ // Do not consume a one-use code if a pairing already exists.
21
+ const reservation=await open(file+".pairing","wx",0o600).catch(()=>{throw new Error("Configuration directory must exist and no pairing may already be in progress.");});
22
+ try {
23
+ try {await loadConfig(file);throw new Error("This configuration is already paired.");}
24
+ catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}
25
+ const server=serverURL(opts.server);
26
+ const result=await new API(server).post("/enroll",{code:opts.code,name:opts.name??hostname()});
27
+ if(typeof result.token!=="string")throw new Error("Invalid enrollment response.");
28
+ await saveConfig(file,{version:2,server,token:result.token},true);
29
+ console.log("Paired. Ask the owner to bind a project and install its repository in the web app.");
30
+ } finally {await reservation.close();await unlink(file+".pairing");}
31
+ return;
32
+ }
33
+ const config=await loadConfig(file);
34
+ if(action==="allow") {
35
+ console.log("The local allow command is no longer needed. Bind this executor to the project and configure its GitHub App repository access in the web app.");return;
36
+ }
37
+ if(action==="status") {
38
+ const session=CodexSession.start(config.codexBinary??"codex",dirname(file));
39
+ try {await session.initialize();console.log(JSON.stringify({codex_authenticated:await session.authenticated(),server:config.server}));}
40
+ finally{session.stop();}return;
41
+ }
42
+ if(action!=="run")throw new Error("Unknown command. Use help.");
43
+ const root=join(dirname(file),"runtime");await mkdir(root,{recursive:true,mode:0o700});
44
+ const lock=join(root,"connector.lock");
45
+ const fd=await open(lock,"wx",0o600).catch(()=>{throw new Error("Connector lock exists. Confirm no connector is running before removing that exact lock file.");});
46
+ await fd.writeFile(String(process.pid));
47
+ const controller=new AbortController();
48
+ const stop=()=>controller.abort();process.once("SIGINT",stop);process.once("SIGTERM",stop);
49
+ try {console.log("Connector running. Press Ctrl+C to stop; interrupted work is preserved.");await runLoop(config,root,controller.signal);}
50
+ finally {controller.abort();process.off("SIGINT",stop);process.off("SIGTERM",stop);await fd.close();await unlink(lock);}
51
+ }
52
+
53
+ if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) {
54
+ main(process.argv.slice(2)).catch(error=>{console.error(error instanceof Error?error.message:"Connector operation failed.");process.exitCode=1;});
55
+ }
package/src/codex.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { createHash } from "node:crypto";
4
+ import { stopProcess } from "./process.ts";
5
+ import { activityForNotification, type ActivityStage } from "./activity.ts";
6
+
7
+ type Pending={resolve:(value:any)=>void;reject:(reason:Error)=>void;timer:NodeJS.Timeout};
8
+
9
+ export class RepeatedFailureError extends Error {
10
+ readonly fingerprint: string;
11
+ constructor(fingerprint:string){super("Codex repeated the same failed command three times.");this.fingerprint=fingerprint;}
12
+ }
13
+ export class CodexBlockedError extends Error {}
14
+
15
+ export class CodexSession {
16
+ readonly child: ChildProcessWithoutNullStreams;
17
+ readonly events=new EventEmitter();
18
+ private pending=new Map<number,Pending>();
19
+ private id=0;
20
+ private buffer="";
21
+ private failed:Error|null=null;
22
+ private commandFailures=new Map<string,number>();
23
+
24
+ constructor(child:ChildProcessWithoutNullStreams) {
25
+ this.child=child;
26
+ child.stderr.resume();
27
+ child.stdin.on("error",()=>this.fail(new Error("Codex transport closed.")));
28
+ child.stdout.on("data",chunk=>this.consume(chunk.toString()));
29
+ child.on("error",()=>this.fail(new Error("Codex could not start. Check the locally installed CLI.")));
30
+ child.on("close",()=>this.fail(new Error("Codex process stopped before completion.")));
31
+ }
32
+
33
+ static start(binary:string,workspace:string):CodexSession {
34
+ // Keep provider-owned local login, but never forward API/Git tokens into the agent.
35
+ const allowed=["PATH","HOME","USER","LOGNAME","SHELL","TMPDIR","LANG","LC_ALL","CODEX_HOME","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY","NO_PROXY"];
36
+ const env=Object.fromEntries(allowed.filter(key=>process.env[key]!==undefined).map(key=>[key,process.env[key]]));
37
+ const args=["app-server","--listen","stdio://",
38
+ "-c",'permissions.dev-orchestrator.extends=":workspace"',
39
+ "-c",'permissions.dev-orchestrator.filesystem={":workspace_roots"={"."="write",".git"="write",".agents"="write",".codex"="read",".aws"="read"}}',
40
+ "-c","permissions.dev-orchestrator.network.enabled=false"];
41
+ return new CodexSession(spawn(binary,args,{cwd:workspace,env,detached:true,stdio:["pipe","pipe","pipe"]}));
42
+ }
43
+
44
+ async initialize():Promise<void> {
45
+ await this.request("initialize",{clientInfo:{name:"dev_orchestrator",title:"Dev Orchestrator",version:"0.1.0"},capabilities:{experimentalApi:true}});
46
+ this.send({method:"initialized",params:{}});
47
+ }
48
+ async authenticated():Promise<boolean> {
49
+ const result=await this.request("account/read",{refreshToken:false});
50
+ return result.account?.type==="chatgpt";
51
+ }
52
+
53
+ async run(workspace:string,prompt:string,model:string|undefined,signal?:AbortSignal,onProgress?:(stage:ActivityStage)=>Promise<void>):Promise<void> {
54
+ const thread=await this.request("thread/start",{cwd:workspace,approvalPolicy:"never",permissions:"dev-orchestrator",...(model?{model}:{})});
55
+ const threadId=thread.thread?.id;
56
+ if(!threadId)throw new Error("Codex returned no task thread.");
57
+ let onNotification:(message:any)=>void;
58
+ let onFailure:(error:Error)=>void;
59
+ let abort:()=>void;
60
+ let outcome:string|undefined;
61
+ let lastStage:ActivityStage|undefined;
62
+ let progressQueue=Promise.resolve();
63
+ let progressFailure:Error|undefined;
64
+ const completed=new Promise<void>((resolve,reject)=>{
65
+ onNotification=(message)=>{
66
+ if(message.params?.threadId===threadId) {
67
+ const stage=activityForNotification(message);
68
+ if(stage && stage!==lastStage) {
69
+ lastStage=stage;
70
+ progressQueue=progressQueue.then(()=>onProgress?.(stage));
71
+ void progressQueue.catch(error=>{
72
+ progressFailure=error instanceof Error?error:new Error("Progress delivery failed.");
73
+ this.fail(progressFailure);
74
+ });
75
+ }
76
+ }
77
+ if(message.method==="item/completed" && message.params?.threadId===threadId && message.params.item?.type==="agentMessage") {
78
+ try {const result=JSON.parse(message.params.item.text);if(["completed","blocked"].includes(result?.outcome))outcome=result.outcome;}catch{/* Wait for a valid structured final answer. */}
79
+ }
80
+ if(message.method==="turn/completed" && message.params?.threadId===threadId) {
81
+ if(outcome==="blocked")reject(new CodexBlockedError("Essential context is missing; the task is blocked."));
82
+ else if(message.params.turn?.status==="completed" && outcome==="completed")resolve();
83
+ else reject(new Error("Codex turn did not complete."));
84
+ }
85
+ };
86
+ onFailure=reject;
87
+ abort=()=>{this.stop();reject(new Error("Task stopped before lease expiration."));};
88
+ this.events.on("notification",onNotification);
89
+ this.events.once("failure",onFailure);
90
+ signal?.addEventListener("abort",abort,{once:true});
91
+ if(signal?.aborted)abort();
92
+ });
93
+ // Attach a rejection handler before turn/start to cover an early process exit.
94
+ completed.catch(()=>{});
95
+ try {
96
+ await this.request("turn/start",{threadId,cwd:workspace,approvalPolicy:"never",permissions:"dev-orchestrator",input:[{type:"text",text:prompt}],outputSchema:{type:"object",properties:{outcome:{type:"string",enum:["completed","blocked"]}},required:["outcome"],additionalProperties:false}});
97
+ await completed;
98
+ await progressQueue;
99
+ if(progressFailure)throw progressFailure;
100
+ } finally {
101
+ this.events.off("notification",onNotification!);this.events.off("failure",onFailure!);
102
+ signal?.removeEventListener("abort",abort!);
103
+ }
104
+ }
105
+
106
+ stop():void {stopProcess(this.child);this.fail(new Error("Codex stopped."));}
107
+
108
+ private request(method:string,params:unknown):Promise<any> {
109
+ if(this.failed)return Promise.reject(this.failed);
110
+ return new Promise((resolve,reject)=>{
111
+ const id=++this.id;
112
+ const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error("Codex request timed out."));this.stop();},30_000);
113
+ this.pending.set(id,{resolve,reject,timer});this.send({id,method,params});
114
+ });
115
+ }
116
+ private send(message:unknown):void {if(!this.child.stdin.destroyed)this.child.stdin.write(JSON.stringify(message)+"\n");}
117
+ private consume(chunk:string):void {
118
+ this.buffer+=chunk;
119
+ if(this.buffer.length>1_048_576){this.fail(new Error("Codex message exceeded the size limit."));this.stop();return;}
120
+ let newline:number;
121
+ while((newline=this.buffer.indexOf("\n"))>=0) {
122
+ const line=this.buffer.slice(0,newline);this.buffer=this.buffer.slice(newline+1);
123
+ if(!line.trim())continue;
124
+ let message:any;
125
+ try{message=JSON.parse(line);}catch{this.fail(new Error("Invalid Codex protocol response."));this.stop();return;}
126
+ if(!message || typeof message!=="object" || Array.isArray(message)){this.fail(new Error("Invalid Codex protocol response."));this.stop();return;}
127
+ if(message.method==="item/completed") {
128
+ const item=message.params?.item;
129
+ if(item?.type==="commandExecution" && (typeof item.exitCode==="number" && item.exitCode!==0 || item.status==="failed")) {
130
+ const command=String(item.command??"");
131
+ const wrapped=command.trim().match(/^(?:\/[^\s]+\/)?(?:bash|zsh|sh)\s+-lc\s+(['"])([\s\S]*)\1$/);
132
+ const probe=(wrapped?.[2]??command).trim();
133
+ const unquoted=probe.replace(/"[^"\n]*"|'[^'\n]*'/g,"");
134
+ const expectedMiss=item.exitCode===1 && /^(?:rg|grep|test|\[)(?:\s|$)/.test(probe) && !/[;&|]/.test(unquoted);
135
+ if(!expectedMiss) {
136
+ const detail=(command+":"+String(item.exitCode)+":"+String(item.aggregatedOutput??"")).replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi,"<id>").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|seconds)\b/g,"<duration>");
137
+ const fingerprint=createHash("sha256").update(detail).digest("hex");
138
+ const count=(this.commandFailures.get(fingerprint)??0)+1;this.commandFailures.set(fingerprint,count);
139
+ if(count>=3){this.fail(new RepeatedFailureError(fingerprint));this.stop();return;}
140
+ }
141
+ }
142
+ }
143
+ if(message.method && message.id!==undefined) {
144
+ // The connector never auto-approves a broader permission or user-input request.
145
+ this.send({id:message.id,error:{code:-32000,message:"Required context or permission is unavailable; task blocked."}});
146
+ this.fail(new Error("Codex needs context or unsupported permissions."));this.stop();return;
147
+ }
148
+ if(message.id!==undefined) {
149
+ const pending=this.pending.get(message.id);
150
+ if(pending){clearTimeout(pending.timer);this.pending.delete(message.id);message.error?pending.reject(new Error("Codex rejected the configured request or sandbox.")):pending.resolve(message.result);}
151
+ } else if(message.method)this.events.emit("notification",message);
152
+ }
153
+ }
154
+ private fail(error:Error):void {
155
+ if(this.failed)return;
156
+ this.failed=error;
157
+ for(const pending of this.pending.values()){clearTimeout(pending.timer);pending.reject(error);}this.pending.clear();
158
+ this.events.emit("failure",error);
159
+ }
160
+ }
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { lstat, readFile, mkdir, open, rename, unlink } from "node:fs/promises";
2
+ import { dirname, isAbsolute } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { Config } from "./types.ts";
5
+
6
+ export function serverURL(value: string): string {
7
+ const url = new URL(value);
8
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
9
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error("Use an HTTPS server origin (loopback HTTP is allowed for development).");
10
+ return url.origin;
11
+ }
12
+
13
+ export async function loadConfig(path: string): Promise<Config> {
14
+ if (!isAbsolute(path)) throw new Error("Configuration path must be absolute.");
15
+ const info = await lstat(path);
16
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new Error("Configuration must be a regular owner-only file (chmod 600).");
17
+ const config = JSON.parse(await readFile(path,"utf8"));
18
+ if (![1,2].includes(config.version) || typeof config.token !== "string" || !config.token) throw new Error("Invalid connector configuration.");
19
+ const server = serverURL(config.server);
20
+ if (config.version === 1) {
21
+ if (!Array.isArray(config.mappings)) throw new Error("Invalid connector configuration.");
22
+ console.warn("Legacy connector configuration detected. Local project mappings are ignored; bind projects in the web app.");
23
+ } else if ("mappings" in config) {
24
+ throw new Error("Invalid connector configuration.");
25
+ }
26
+ return {version:2,server,token:config.token,...(typeof config.codexBinary === "string"?{codexBinary:config.codexBinary}:{})};
27
+ }
28
+
29
+ export async function saveConfig(path: string, config: Config, exclusive = false): Promise<void> {
30
+ if (!isAbsolute(path)) throw new Error("Configuration path must be absolute.");
31
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
32
+ if (!exclusive) {
33
+ try { await loadConfig(path); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; }
34
+ }
35
+ if (exclusive) {
36
+ const file = await open(path,"wx",0o600);
37
+ try { await file.writeFile(JSON.stringify(config,null,2)); await file.sync(); } finally { await file.close(); }
38
+ } else {
39
+ const temporary = path + ".tmp-" + randomUUID();
40
+ const file = await open(temporary,"wx",0o600);
41
+ try {
42
+ await file.writeFile(JSON.stringify(config,null,2)); await file.sync();
43
+ await file.close(); await rename(temporary,path);
44
+ } catch (error) { await file.close().catch(()=>{}); await unlink(temporary).catch(()=>{}); throw error; }
45
+ }
46
+ }
@@ -0,0 +1,29 @@
1
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { GitHubCredential } from "./types.ts";
4
+ import { cleanGitEnv } from "./process.ts";
5
+
6
+ export function validateGitHubCredential(value: unknown): asserts value is GitHubCredential {
7
+ if (!value || typeof value !== "object") throw new Error("GitHub authorization is unavailable.");
8
+ const credential=value as Partial<GitHubCredential>;
9
+ if (typeof credential.token!=="string" || !/^[A-Za-z0-9_-]+$/.test(credential.token) ||
10
+ typeof credential.expires_at!=="string" || !Number.isFinite(Date.parse(credential.expires_at)) || Date.parse(credential.expires_at)<=Date.now() ||
11
+ typeof credential.author_name!=="string" || !credential.author_name || credential.author_name.length>200 || /[\r\n]/.test(credential.author_name) ||
12
+ typeof credential.author_email!=="string" || !credential.author_email || credential.author_email.length>254 || /[\r\n]/.test(credential.author_email)) {
13
+ throw new Error("GitHub authorization is invalid or expired.");
14
+ }
15
+ }
16
+
17
+ export async function withGitHubCredential<T>(root:string, credential:GitHubCredential, operation:(env:NodeJS.ProcessEnv)=>Promise<T>):Promise<T> {
18
+ validateGitHubCredential(credential);
19
+ const directory=await mkdtemp(join(root,".github-auth-"));
20
+ try {
21
+ await chmod(directory,0o700);
22
+ const helper=join(directory,"askpass");
23
+ const script=`#!/bin/sh\ncase "$1" in\n *sername*) printf '%s\\n' 'x-access-token' ;;\n *assword*) printf '%s\\n' '${credential.token}' ;;\n *) exit 1 ;;\nesac\n`;
24
+ await writeFile(helper,script,{encoding:"utf8",mode:0o700,flag:"wx"});
25
+ return await operation({...cleanGitEnv(),GIT_ASKPASS:helper,SSH_ASKPASS:helper,GIT_TERMINAL_PROMPT:"0"});
26
+ } finally {
27
+ await rm(directory,{recursive:true,force:true});
28
+ }
29
+ }
package/src/journal.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { mkdir, open, readFile, rename, unlink, lstat } from "node:fs/promises";
2
+ import { join, isAbsolute } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { JournalState } from "./types.ts";
5
+
6
+ export class Journal {
7
+ readonly root: string;
8
+ constructor(root: string) {
9
+ if (!isAbsolute(root)) throw new Error("Journal directory must be absolute.");
10
+ this.root = root;
11
+ }
12
+ async load(): Promise<JournalState | null> {
13
+ try {
14
+ const path = join(this.root,"active.json");
15
+ const stat = await lstat(path);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error("Unsafe journal file.");
17
+ const state = JSON.parse(await readFile(path,"utf8"));
18
+ if (!state.lease?.id || !Number.isInteger(state.sequence)) throw new Error("Corrupt journal; manual recovery is required.");
19
+ return state;
20
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
21
+ }
22
+ async save(state: JournalState): Promise<void> {
23
+ await mkdir(this.root,{recursive:true,mode:0o700});
24
+ const info = await lstat(this.root);
25
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe journal directory.");
26
+ const temp = join(this.root,".journal-" + randomUUID());
27
+ const file = await open(temp,"wx",0o600);
28
+ try {
29
+ await file.writeFile(JSON.stringify(state)); await file.sync(); await file.close();
30
+ await rename(temp,join(this.root,"active.json"));
31
+ } catch (error) { await file.close().catch(()=>{}); await unlink(temp).catch(()=>{}); throw error; }
32
+ }
33
+ async clear(): Promise<void> {
34
+ await unlink(join(this.root,"active.json")).catch((e: NodeJS.ErrnoException) => { if (e.code !== "ENOENT") throw e; });
35
+ }
36
+ }
package/src/process.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ export const cleanGitEnv = (): NodeJS.ProcessEnv => ({
4
+ PATH: process.env.PATH, HOME: process.env.HOME,
5
+ LANG: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null",
6
+ GIT_TERMINAL_PROMPT: "0", GIT_NO_REPLACE_OBJECTS: "1"
7
+ });
8
+
9
+ export async function readAuthor(source:string,env:NodeJS.ProcessEnv={PATH:process.env.PATH,HOME:process.env.HOME,XDG_CONFIG_HOME:process.env.XDG_CONFIG_HOME,GIT_CONFIG_NOSYSTEM:"1"}):Promise<{name:string;email:string}> {
10
+ // Reading these two values does not execute hooks or credential helpers.
11
+ const name=(await command("git",["-C",source,"config","--get","user.name"],{env})).output;
12
+ const email=(await command("git",["-C",source,"config","--get","user.email"],{env})).output;
13
+ if(!name || name.length>200 || !email || email.length>254 || /[\r\n]/.test(name+email))throw new Error("Configure a Git author on the customer host before running tasks.");
14
+ return {name,email};
15
+ }
16
+
17
+ export function stopProcess(child: ChildProcess): void {
18
+ if (!child.pid || child.exitCode !== null) return;
19
+ try { process.kill(-child.pid,"SIGKILL"); } catch { child.kill("SIGKILL"); }
20
+ }
21
+
22
+ export async function command(binary: string, args: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout?: number; allowExitOne?: boolean } = {}): Promise<{ output: string; code: number }> {
23
+ return new Promise((resolve,reject)=>{
24
+ const child=spawn(binary,args,{cwd:opts.cwd,env:opts.env ?? cleanGitEnv(),detached:true,stdio:["ignore","pipe","ignore"]});
25
+ let output="",settled=false;
26
+ const fail=()=>{if(!settled){settled=true;stopProcess(child);reject(new Error("Local command failed."));}};
27
+ const timer=setTimeout(fail,opts.timeout ?? 30_000);
28
+ const abort=()=>fail();
29
+ opts.signal?.addEventListener("abort",abort,{once:true});
30
+ if(opts.signal?.aborted) fail();
31
+ child.stdout.on("data",chunk=>{output+=chunk.toString();if(output.length>1_048_576)fail();});
32
+ child.on("error",fail);
33
+ child.on("close",code=>{
34
+ clearTimeout(timer);opts.signal?.removeEventListener("abort",abort);
35
+ if(settled)return;settled=true;
36
+ if(code===0 || (code===1 && opts.allowExitOne))resolve({output:output.trim(),code:code!});
37
+ else reject(new Error("Local command failed."));
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,152 @@
1
+ import { mkdtemp, readFile, writeFile, rm, realpath, lstat, access, mkdir } from "node:fs/promises";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { command, cleanGitEnv } from "./process.ts";
5
+ import { withGitHubCredential, validateGitHubCredential } from "./git-credential.ts";
6
+ import type { GitHubCredential } from "./types.ts";
7
+ import { validBranch, validRepository } from "./workspace.ts";
8
+
9
+ export type PublicationContext={workspace:string;repository:string;baseBranch:string;branch:string;baseline:string};
10
+ type Dependencies={runCommand?:typeof command;apiFetch?:typeof fetch;remoteURL?:(repository:string)=>string};
11
+ const validSHA=(sha:string)=>/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sha);
12
+
13
+ export class Publisher {
14
+ readonly context:PublicationContext;
15
+ private readonly runCommand:typeof command;
16
+ private readonly apiFetch:typeof fetch;
17
+ private readonly remoteURL:(repository:string)=>string;
18
+
19
+ constructor(context:PublicationContext,dependencies:Dependencies={}) {
20
+ if(!validRepository(context.repository) || !validBranch(context.branch) || !validBranch(context.baseBranch) || context.branch===context.baseBranch || !context.branch.startsWith("task/") || !validSHA(context.baseline))throw new Error("Invalid publication context.");
21
+ this.context=context;
22
+ this.runCommand=dependencies.runCommand??command;
23
+ this.apiFetch=dependencies.apiFetch??fetch;
24
+ this.remoteURL=dependencies.remoteURL??(repository=>`https://github.com/${repository}.git`);
25
+ }
26
+
27
+ async status(signal?:AbortSignal):Promise<{changed:boolean;commit:string}> {
28
+ return this.trusted(async(git,sha)=>{
29
+ const uncommitted=await git(["diff","--no-ext-diff","--no-textconv","--quiet",sha,"--"],true);
30
+ const untracked=await git(["ls-files","--others","--exclude-standard"]);
31
+ if(uncommitted.code!==0 || untracked.output)throw new Error("Uncommitted changes remain. Codex must commit the task before publication.");
32
+ const diff=await git(["diff","--no-ext-diff","--no-textconv","--quiet",this.context.baseline,sha,"--"],true);
33
+ return {changed:diff.code===1,commit:sha};
34
+ },signal);
35
+ }
36
+
37
+ async publish(title:string,body:string,credential:GitHubCredential,signal?:AbortSignal):Promise<Record<string,string>> {
38
+ if(!title.trim() || title.length>240 || body.length>64_000)throw new Error("Invalid PR metadata.");
39
+ validateGitHubCredential(credential);
40
+ const status=await this.status(signal);
41
+ if(!status.changed)throw new Error("No committed changes to publish.");
42
+ return this.trusted(async(git,sha,temp,objects)=>{
43
+ await withGitHubCredential(temp,credential,env=>git(["push","--no-verify",this.remoteURL(this.context.repository),`${sha}:refs/heads/${this.context.branch}`],false,{...env,GIT_OBJECT_DIRECTORY:objects}));
44
+ let pr=await this.findOpenPullRequest(credential,signal);
45
+ if(!pr) {
46
+ const [owner]=this.context.repository.split("/");
47
+ const created=await this.githubRequest("POST",`/repos/${this.context.repository}/pulls`,credential,{
48
+ title,
49
+ head:`${owner}:${this.context.branch}`,
50
+ base:this.context.baseBranch,
51
+ body,
52
+ draft:false
53
+ },signal);
54
+ if(created.status===422)pr=await this.findOpenPullRequest(credential,signal);
55
+ else if(created.ok)pr=created.body;
56
+ else throw new Error("GitHub could not create a pull request for this branch.");
57
+ }
58
+ if(!this.matchesPullRequest(pr,sha))throw new Error("GitHub pull request does not match the task commit.");
59
+ return {repository:this.context.repository,branch:this.context.branch,base_branch:this.context.baseBranch,commit:sha,pr_url:pr.html_url};
60
+ },signal);
61
+ }
62
+
63
+ private async findOpenPullRequest(credential:GitHubCredential,signal?:AbortSignal):Promise<any|undefined> {
64
+ const [owner]=this.context.repository.split("/");
65
+ const query=new URLSearchParams({state:"open",head:`${owner}:${this.context.branch}`,per_page:"100"});
66
+ const response=await this.githubRequest("GET",`/repos/${this.context.repository}/pulls?${query}`,credential,undefined,signal);
67
+ if(!response.ok || !Array.isArray(response.body))throw new Error("GitHub pull request lookup failed.");
68
+ const sameBranch=response.body.filter((pr:any)=>pr?.head?.ref===this.context.branch && pr?.head?.repo?.full_name===this.context.repository);
69
+ const exact=sameBranch.find((pr:any)=>pr?.base?.ref===this.context.baseBranch && pr?.base?.repo?.full_name===this.context.repository);
70
+ if(sameBranch.length && !exact)throw new Error("An open pull request exists for this task branch with a different base repository or branch.");
71
+ return exact;
72
+ }
73
+
74
+ private matchesPullRequest(pr:any,sha:string):boolean {
75
+ const escaped=this.context.repository.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
76
+ return pr?.state==="open" && pr?.head?.sha===sha && pr?.head?.ref===this.context.branch &&
77
+ pr?.head?.repo?.full_name===this.context.repository && pr?.base?.ref===this.context.baseBranch &&
78
+ pr?.base?.repo?.full_name===this.context.repository &&
79
+ new RegExp(`^https://github\\.com/${escaped}/pull/[0-9]+$`).test(pr?.html_url??"");
80
+ }
81
+
82
+ private async githubRequest(method:"GET"|"POST",path:string,credential:GitHubCredential,body?:Record<string,string|boolean>,signal?:AbortSignal):Promise<{ok:boolean;status:number;body:any}> {
83
+ const timeout=AbortSignal.timeout(15_000);
84
+ const requestSignal=signal?AbortSignal.any([signal,timeout]):timeout;
85
+ let response:Response;
86
+ try {
87
+ response=await this.apiFetch("https://api.github.com"+path,{
88
+ method,redirect:"error",signal:requestSignal,
89
+ headers:{authorization:`Bearer ${credential.token}`,accept:"application/vnd.github+json","x-github-api-version":"2022-11-28","content-type":"application/json","user-agent":"dev-orchestrator"},
90
+ ...(body?{body:JSON.stringify(body)}:{})
91
+ });
92
+ } catch {throw new Error("GitHub pull request service is temporarily unavailable.");}
93
+ let payload:any;
94
+ try {payload=await response.json();} catch {throw new Error("GitHub returned an invalid pull request response.");}
95
+ return {ok:response.ok,status:response.status,body:payload};
96
+ }
97
+
98
+ private async trusted<T>(fn:(git:(args:string[],allowExitOne?:boolean,commandEnv?:NodeJS.ProcessEnv)=>Promise<{output:string;code:number}>,sha:string,temp:string,objects:string)=>Promise<T>,signal?:AbortSignal):Promise<T> {
99
+ const workspace=await realpath(this.context.workspace);
100
+ const pointer=join(workspace,".git");
101
+ const stat=await lstat(pointer);
102
+ if(stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile()))throw new Error("Publication needs valid Git metadata.");
103
+ let gitDir:string;
104
+ let commonDir:string;
105
+ if(stat.isDirectory()) {
106
+ gitDir=workspace+sep+".git";
107
+ commonDir=gitDir;
108
+ } else {
109
+ const content=await readFile(pointer,"utf8");
110
+ const match=content.match(/^gitdir: (.+)\r?\n?$/);
111
+ if(!match)throw new Error("Publication needs valid Git worktree metadata.");
112
+ gitDir=await realpath(resolve(workspace,match[1]));
113
+ commonDir=await realpath(join(gitDir,"..",".."));
114
+ const worktrees=await realpath(join(commonDir,"worktrees"));
115
+ if(!gitDir.startsWith(worktrees+sep) || gitDir.slice(worktrees.length+1).includes(sep))throw new Error("Publication worktree metadata escaped its repository.");
116
+ }
117
+ const objects=await realpath(join(commonDir,"objects"));
118
+ if(!objects.startsWith(commonDir+sep))throw new Error("Publication needs an internal Git object directory.");
119
+ for(const forbidden of ["info/alternates","info/http-alternates"]) {
120
+ try {await access(join(objects,forbidden));throw new Error("External Git object stores are not supported.");}
121
+ catch(error) {if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}
122
+ }
123
+ const head=await readFile(join(gitDir,"HEAD"),"utf8");
124
+ if(head.trim()!==`ref: refs/heads/${this.context.branch}`)throw new Error("Task branch changed unexpectedly.");
125
+ const ref=join(commonDir,"refs","heads",this.context.branch);
126
+ const resolved=await realpath(ref);
127
+ if(!resolved.startsWith(commonDir+sep))throw new Error("Unsafe Git reference.");
128
+ const sha=(await readFile(ref,"utf8")).trim();
129
+ if(!validSHA(sha))throw new Error("Invalid task commit.");
130
+ const temporary=await mkdtemp(join(tmpdir(),"dev-orchestrator-publish-"));
131
+ try {
132
+ const bare=join(temporary,"repo.git");
133
+ await this.runCommand("git",["init","--bare","--template=",bare],{signal});
134
+ await mkdir(join(bare,"info"),{mode:0o700});
135
+ const excludes=join(commonDir,"info","exclude");
136
+ const excludesInfo=await lstat(excludes).catch(error=>{if((error as NodeJS.ErrnoException).code==="ENOENT")return undefined;throw error;});
137
+ if(excludesInfo) {
138
+ if(!excludesInfo.isFile() || excludesInfo.isSymbolicLink() || excludesInfo.size>1_048_576)throw new Error("Unsafe Git exclude metadata.");
139
+ await writeFile(join(bare,"info","exclude"),await readFile(excludes,"utf8"),{mode:0o600});
140
+ }
141
+ try {
142
+ const shallow=await readFile(join(commonDir,"shallow"),"utf8");
143
+ if(!shallow.trim().split("\n").every(validSHA))throw new Error("Invalid shallow boundary.");
144
+ await writeFile(join(bare,"shallow"),shallow,{mode:0o600});
145
+ } catch(error) {if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}
146
+ const env={...cleanGitEnv(),GIT_OBJECT_DIRECTORY:objects};
147
+ const git=(args:string[],allowExitOne=false,commandEnv:NodeJS.ProcessEnv=env)=>this.runCommand("git",["--git-dir",bare,"--work-tree",workspace,"-c","core.bare=false","-c","core.hooksPath=/dev/null",...args],{cwd:temporary,env:commandEnv,signal,allowExitOne});
148
+ await git(["read-tree",sha]);
149
+ return await fn(git,sha,temporary,objects);
150
+ } finally {await rm(temporary,{recursive:true,force:true});}
151
+ }
152
+ }
@@ -0,0 +1,261 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { command } from "./process.ts";
5
+ import { withGitHubCredential } from "./git-credential.ts";
6
+ import type { GitHubCredential, Lease } from "./types.ts";
7
+ import { validBranch, validRepository } from "./workspace.ts";
8
+
9
+ type Marker={run:string;lifecycle:number;project:string;repository:string;base_branch:string;baseline:string;branch:string};
10
+ type Options={remoteURL?:(repository:string)=>string;runCommand?:typeof command};
11
+ const validId=(value:unknown):value is string=>typeof value==="string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
12
+ const validSHA=(value:unknown):value is string=>typeof value==="string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
13
+
14
+ async function ensureDirectory(path:string):Promise<string> {
15
+ await mkdir(path,{recursive:true,mode:0o700});
16
+ const info=await lstat(path);
17
+ const canonical=await realpath(path);
18
+ if(!info.isDirectory() || info.isSymbolicLink() || canonical!==resolve(path))throw new Error("Managed repository storage must be a canonical directory without symlinks.");
19
+ await chmod(path,0o700);
20
+ return canonical;
21
+ }
22
+
23
+ async function acquireLock(path:string):Promise<()=>Promise<void>> {
24
+ const deadline=Date.now()+30_000;
25
+ while(Date.now()<deadline) {
26
+ try {
27
+ const file=await open(path,"wx",0o600);
28
+ await file.writeFile(String(process.pid));await file.sync();await file.close();
29
+ return async()=>{await unlink(path).catch(error=>{if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;});};
30
+ } catch(error) {
31
+ const code=(error as NodeJS.ErrnoException).code;
32
+ if(code!=="EEXIST")throw error;
33
+ const info=await lstat(path).catch(()=>undefined);
34
+ if(info?.isSymbolicLink() || (info && !info.isFile()))throw new Error("Unsafe repository cache lock.");
35
+ const value=info?await readFile(path,"utf8").catch(()=>""):"";
36
+ const pid=Number(value.trim());
37
+ if(Number.isInteger(pid) && pid>0 && pid!==process.pid) {
38
+ try {process.kill(pid,0);} catch(e) {
39
+ if((e as NodeJS.ErrnoException).code==="ESRCH") {await unlink(path).catch(()=>{});continue;}
40
+ }
41
+ }
42
+ await new Promise(resolve=>setTimeout(resolve,100));
43
+ }
44
+ }
45
+ throw new Error("Repository cache is busy. Retry after the active repository operation finishes.");
46
+ }
47
+
48
+ async function worktreeGitDirectory(workspace:string,cache:string):Promise<string> {
49
+ const pointer=join(workspace,".git");
50
+ const info=await lstat(pointer);
51
+ if(!info.isFile() || info.isSymbolicLink())throw new Error("Task worktree Git pointer is invalid.");
52
+ const contents=await readFile(pointer,"utf8");
53
+ const match=contents.match(/^gitdir: (.+)\r?\n?$/);
54
+ if(!match)throw new Error("Task worktree Git pointer is invalid.");
55
+ const gitdir=await realpath(resolve(workspace,match[1]));
56
+ const worktrees=await realpath(join(cache,"worktrees"));
57
+ if(!gitdir.startsWith(worktrees+sep) || relative(worktrees,gitdir).split(sep).length!==1)throw new Error("Task worktree metadata escaped its managed repository.");
58
+ const gitInfo=await lstat(gitdir);
59
+ if(!gitInfo.isDirectory() || gitInfo.isSymbolicLink())throw new Error("Task worktree metadata is invalid.");
60
+ return gitdir;
61
+ }
62
+
63
+ async function readMarker(workspace:string,cache:string):Promise<Marker> {
64
+ const gitdir=await worktreeGitDirectory(workspace,cache);
65
+ const path=join(gitdir,"orchestrator.json");
66
+ const info=await lstat(path);
67
+ if(!info.isFile() || info.isSymbolicLink() || (info.mode&0o077)!==0)throw new Error("Task worktree marker is unsafe.");
68
+ const marker=JSON.parse(await readFile(path,"utf8")) as Marker;
69
+ if(!validId(marker.run) || !validId(marker.project) || !Number.isInteger(marker.lifecycle) || marker.lifecycle<1 || !validRepository(marker.repository) || !validBranch(marker.base_branch) || !validBranch(marker.branch) || !validSHA(marker.baseline))throw new Error("Task worktree marker is invalid.");
70
+ return marker;
71
+ }
72
+
73
+ async function workspaceCache(workspace:string):Promise<string> {
74
+ const pointer=await readFile(join(workspace,".git"),"utf8");
75
+ const match=pointer.match(/^gitdir: (.+)\r?\n?$/);
76
+ if(!match)throw new Error("Task worktree Git pointer is invalid.");
77
+ const gitdir=await realpath(resolve(workspace,match[1]));
78
+ return realpath(join(gitdir,"..",".."));
79
+ }
80
+
81
+ export class RepositoryWorkspace {
82
+ private readonly remoteURL:(repository:string)=>string;
83
+ private readonly runCommand:typeof command;
84
+
85
+ constructor(options:Options={}) {
86
+ this.remoteURL=options.remoteURL??(repository=>`https://github.com/${repository}.git`);
87
+ this.runCommand=options.runCommand??command;
88
+ }
89
+
90
+ async prepare(root:string,lease:Lease,credential:GitHubCredential):Promise<{path:string;branch:string;baseline:string}> {
91
+ const projectId=lease.project_id;
92
+ const repository=lease.snapshot?.repository;
93
+ const baseBranch=lease.snapshot?.base_branch;
94
+ if(!isAbsolute(root) || !validId(lease.id) || !validId(projectId) || !Number.isInteger(lease.lifecycle) || lease.lifecycle<1 || !validRepository(repository) || !validBranch(baseBranch))throw new Error("Task repository snapshot is invalid.");
95
+ const dataRoot=await ensureDirectory(root);
96
+ const repositories=await ensureDirectory(join(dataRoot,"repositories"));
97
+ const workspaces=await ensureDirectory(join(dataRoot,"workspaces"));
98
+ const cacheKey=createHash("sha256").update(repository).digest("hex").slice(0,16);
99
+ const cache=join(repositories,`${projectId}-${cacheKey}`);
100
+ const workspace=join(workspaces,`${lease.id}-${lease.lifecycle}`);
101
+ const branch=`task/${lease.id}`;
102
+ const remote=this.remoteURL(repository);
103
+ const release=await acquireLock(cache+".lock");
104
+ try {
105
+ return await withGitHubCredential(dataRoot,credential,async env=>{
106
+ const git=(args:string[],cwd?:string)=>this.runCommand("git",args,{cwd,env,timeout:120_000});
107
+ let cacheInfo=await lstat(cache).catch(error=>{if((error as NodeJS.ErrnoException).code==="ENOENT")return undefined;throw error;});
108
+ if(cacheInfo) {
109
+ if(!cacheInfo.isDirectory() || cacheInfo.isSymbolicLink() || await realpath(cache)!==cache)throw new Error("Managed repository cache is unsafe.");
110
+ if((await git(["--git-dir",cache,"rev-parse","--is-bare-repository"])).output!=="true")throw new Error("Managed repository cache is invalid.");
111
+ const existing=(await git(["--git-dir",cache,"remote","get-url","origin"])).output;
112
+ if(existing!==remote)throw new Error("Managed repository cache does not match the selected repository.");
113
+ } else {
114
+ await git(["-c","core.hooksPath=/dev/null","clone","--bare","--template=","--",remote,cache]);
115
+ cacheInfo=await lstat(cache);
116
+ if(!cacheInfo.isDirectory() || cacheInfo.isSymbolicLink())throw new Error("Repository checkout did not create a safe cache.");
117
+ await chmod(cache,0o700);
118
+ }
119
+ await git(["--git-dir",cache,"-c","core.hooksPath=/dev/null","fetch","--prune","origin",`+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`]);
120
+ const baseline=(await git(["--git-dir",cache,"rev-parse",`refs/remotes/origin/${baseBranch}^{commit}`])).output;
121
+ if(!validSHA(baseline))throw new Error("Selected base branch does not resolve to a commit.");
122
+
123
+ const existingWorkspace=await lstat(workspace).catch(error=>{if((error as NodeJS.ErrnoException).code==="ENOENT")return undefined;throw error;});
124
+ if(existingWorkspace) {
125
+ if(!existingWorkspace.isDirectory() || existingWorkspace.isSymbolicLink() || await realpath(workspace)!==workspace)throw new Error("Existing task worktree is unsafe.");
126
+ const marker=await readMarker(workspace,cache);
127
+ if(marker.run!==lease.id || marker.lifecycle!==lease.lifecycle || marker.project!==projectId || marker.repository!==repository || marker.base_branch!==baseBranch || marker.branch!==branch || !validSHA(marker.baseline))throw new Error("Existing task worktree identity does not match this lease.");
128
+ return {path:workspace,branch,baseline:marker.baseline};
129
+ }
130
+
131
+ await git(["--git-dir",cache,"worktree","add","--force","--detach",workspace,baseline]);
132
+ await git(["-C",workspace,"-c","core.hooksPath=/dev/null","switch","-C",branch,"--ignore-other-worktrees",baseline]);
133
+ await git(["-C",workspace,"config","user.name",credential.author_name]);
134
+ await git(["-C",workspace,"config","user.email",credential.author_email]);
135
+ await git(["-C",workspace,"config","core.hooksPath","/dev/null"]);
136
+ const gitdir=await worktreeGitDirectory(workspace,cache);
137
+ await writeFile(join(gitdir,"orchestrator.json"),JSON.stringify({run:lease.id,lifecycle:lease.lifecycle,project:projectId,repository,base_branch:baseBranch,baseline,branch} satisfies Marker),{mode:0o600,flag:"wx"});
138
+ return {path:workspace,branch,baseline};
139
+ });
140
+ } catch(error) {
141
+ throw error instanceof Error && /Task repository|Managed repository|Selected base|Existing task|Task worktree/.test(error.message)
142
+ ? error
143
+ : new Error("Repository setup failed. Check GitHub App access, repository selection, base branch, and local storage.",{cause:error});
144
+ } finally {
145
+ await release();
146
+ }
147
+ }
148
+
149
+ async writeProjectInstructions(workspace:string,instructions:unknown):Promise<string|undefined> {
150
+ if(typeof instructions!=="string" || Buffer.byteLength(instructions,"utf8")>32_000)throw new Error("Project instructions snapshot is invalid.");
151
+ const canonical=await realpath(workspace);
152
+ if(!isAbsolute(workspace) || canonical!==resolve(workspace))throw new Error("Task worktree path must be canonical.");
153
+ const pointer=await readFile(join(canonical,".git"),"utf8");
154
+ const match=pointer.match(/^gitdir: (.+)\r?\n?$/);
155
+ if(!match)throw new Error("Task worktree Git pointer is invalid.");
156
+ const gitdir=await realpath(resolve(canonical,match[1]));
157
+ const cache=dirname(dirname(gitdir));
158
+ const marker=await readMarker(canonical,cache);
159
+ const target=join(canonical,`.dev-orchestrator-instructions-${marker.run}-${marker.lifecycle}.md`);
160
+ const release=await acquireLock(cache+".lock");
161
+ try {
162
+ const existing=await lstat(target).catch(error=>{if((error as NodeJS.ErrnoException).code==="ENOENT")return undefined;throw error;});
163
+ if(existing?.isSymbolicLink() || (existing && !existing.isFile()))throw new Error("Project instructions target is unsafe.");
164
+ if(instructions==="") {
165
+ if(existing)await unlink(target);
166
+ return undefined;
167
+ }
168
+ if(existing) {
169
+ if(await readFile(target,"utf8")!==instructions)throw new Error("Project instructions target already exists with different content.");
170
+ return target;
171
+ }
172
+ const excludeDirPath=join(cache,"info");
173
+ await mkdir(excludeDirPath,{recursive:true,mode:0o700});
174
+ const excludeDir=await realpath(excludeDirPath);
175
+ if(!excludeDir.startsWith(cache+sep))throw new Error("Worktree ignore metadata escaped its Git directory.");
176
+ const excludeDirInfo=await lstat(excludeDirPath);
177
+ if(!excludeDirInfo.isDirectory() || excludeDirInfo.isSymbolicLink())throw new Error("Worktree ignore metadata is unsafe.");
178
+ const excludePath=join(excludeDir,"exclude");
179
+ await writeFile(excludePath,"",{encoding:"utf8",mode:0o600,flag:"wx"}).catch(error=>{
180
+ if((error as NodeJS.ErrnoException).code!=="EEXIST")throw error;
181
+ });
182
+ const excludeInfo=await lstat(excludePath);
183
+ if(!excludeInfo.isFile() || excludeInfo.isSymbolicLink())throw new Error("Worktree ignore metadata is unsafe.");
184
+ const exclude=await readFile(excludePath,"utf8");
185
+ const rule=`/${basename(target)}`;
186
+ if(!exclude.split(/\r?\n/).includes(rule)) {
187
+ const excludeTemp=join(excludeDir,`.exclude-${randomUUID()}`);
188
+ try {
189
+ await writeFile(excludeTemp,exclude+(exclude.endsWith("\n")||exclude===""?"":"\n")+rule+"\n",{encoding:"utf8",mode:0o600,flag:"wx"});
190
+ await rename(excludeTemp,excludePath);
191
+ } catch(error) {
192
+ await unlink(excludeTemp).catch(()=>{});
193
+ throw error;
194
+ }
195
+ }
196
+ const temporary=join(canonical,`.instructions-${randomUUID()}`);
197
+ try {
198
+ await writeFile(temporary,instructions,{encoding:"utf8",mode:0o600,flag:"wx"});
199
+ await chmod(temporary,0o600);
200
+ await rename(temporary,target);
201
+ return target;
202
+ } catch(error) {
203
+ await unlink(temporary).catch(()=>{});
204
+ throw error;
205
+ }
206
+ } finally {await release();}
207
+ }
208
+
209
+ async markTerminal(workspace:string,outcome:"success"|"failure"):Promise<void> {
210
+ const canonical=await realpath(workspace);
211
+ if(!isAbsolute(workspace) || canonical!==resolve(workspace))throw new Error("Task worktree path must be canonical.");
212
+ const cache=await workspaceCache(canonical);
213
+ const gitdir=await worktreeGitDirectory(canonical,cache);
214
+ await readMarker(canonical,cache);
215
+ const terminalPath=join(gitdir,"terminal.json");
216
+ const existing=await lstat(terminalPath).catch(error=>{if((error as NodeJS.ErrnoException).code==="ENOENT")return undefined;throw error;});
217
+ if(existing) {
218
+ if(!existing.isFile() || existing.isSymbolicLink() || (existing.mode&0o077)!==0)throw new Error("Task terminal marker is unsafe.");
219
+ const previous=JSON.parse(await readFile(terminalPath,"utf8")) as {outcome?:unknown};
220
+ if(previous.outcome!==outcome)throw new Error("Task terminal outcome changed unexpectedly.");
221
+ return;
222
+ }
223
+ await writeFile(terminalPath,JSON.stringify({outcome,recorded_at:Date.now()}),{mode:0o600,flag:"wx"});
224
+ }
225
+
226
+ async cleanupExpired(root:string,protectedWorkspace?:string,now=Date.now()):Promise<number> {
227
+ const canonicalRoot=await ensureDirectory(root);
228
+ const workspacesRoot=join(canonicalRoot,"workspaces");
229
+ const entries=await readdir(workspacesRoot,{withFileTypes:true}).catch(error=>{
230
+ if((error as NodeJS.ErrnoException).code==="ENOENT")return [];
231
+ throw error;
232
+ });
233
+ let removed=0;
234
+ for(const entry of entries) {
235
+ if(!entry.isDirectory() || entry.isSymbolicLink())continue;
236
+ const workspace=join(workspacesRoot,entry.name);
237
+ if(workspace===protectedWorkspace)continue;
238
+ try {
239
+ if(await realpath(workspace)!==workspace)continue;
240
+ const cache=await workspaceCache(workspace);
241
+ const marker=await readMarker(workspace,cache);
242
+ if(entry.name!==`${marker.run}-${marker.lifecycle}`)continue;
243
+ const terminalPath=join(await worktreeGitDirectory(workspace,cache),"terminal.json");
244
+ const terminalInfo=await lstat(terminalPath);
245
+ if(!terminalInfo.isFile() || terminalInfo.isSymbolicLink() || (terminalInfo.mode&0o077)!==0)continue;
246
+ const terminal=JSON.parse(await readFile(terminalPath,"utf8")) as {outcome?:unknown;recorded_at?:unknown};
247
+ if(!["success","failure"].includes(String(terminal.outcome)) || typeof terminal.recorded_at!=="number")continue;
248
+ const retention=terminal.outcome==="success"?24*60*60_000:7*24*60*60_000;
249
+ if(now-terminal.recorded_at<retention)continue;
250
+ if(await realpath(cache)!==cache)continue;
251
+ const release=await acquireLock(cache+".lock");
252
+ try {
253
+ await this.runCommand("git",["--git-dir",cache,"-c","core.hooksPath=/dev/null","worktree","remove","--force",workspace],{env:{PATH:process.env.PATH,HOME:process.env.HOME,GIT_CONFIG_NOSYSTEM:"1",GIT_CONFIG_GLOBAL:"/dev/null"},timeout:30_000});
254
+ await rm(workspace,{recursive:true,force:true});
255
+ removed++;
256
+ } finally {await release();}
257
+ } catch { /* Unknown or unsafe worktrees are retained for manual recovery. */ }
258
+ }
259
+ return removed;
260
+ }
261
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type Config = { version: 2; server: string; token: string; codexBinary?: string };
2
+ export type TokenPurpose = "checkout" | "publish";
3
+ export type GitHubCredential = { token: string; expires_at: string; author_name: string; author_email: string };
4
+ export type Lease = { id: string; generation: number; lifecycle: number; project_id?: string; title?: string; prompt?: string; source_key?: string; snapshot?: Record<string, unknown>; expires_in_ms?: number; sequence?: number };
5
+ export type RunEvent = { sequence: number; kind: string; data: Record<string, unknown> };
6
+ export type JournalState = { lease: Lease; sequence: number; pending_event?: RunEvent; workspace?: string; baseline?: string };
package/src/worker.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { createHash } from "node:crypto";
2
+ import { dirname } from "node:path";
3
+ import { Journal } from "./journal.ts";
4
+ import { API, APIError } from "./api.ts";
5
+ import { CodexSession, RepeatedFailureError, CodexBlockedError } from "./codex.ts";
6
+ import { RepositoryWorkspace } from "./repository.ts";
7
+ import { Publisher, type PublicationContext } from "./publisher.ts";
8
+ import type { Config, JournalState } from "./types.ts";
9
+
10
+ type Transport={post:(path:string,payload:unknown)=>Promise<any>};
11
+ type ExecutionDependencies={dataRoot?:string;repositories?:RepositoryWorkspace;publisherFactory?:(context:PublicationContext)=>Publisher};
12
+
13
+ export async function emitDurably(api:Transport,journal:Journal,state:JournalState,kind:string,data:Record<string,unknown>):Promise<any> {
14
+ if(state.pending_event)throw new Error("A previous event still needs acknowledgement.");
15
+ state.sequence++;
16
+ state.pending_event={sequence:state.sequence,kind,data};
17
+ await journal.save(state);
18
+ const result=await api.post("/executor/events",{lease:state.lease,event:state.pending_event});
19
+ delete state.pending_event;
20
+ await journal.save(state);
21
+ return result;
22
+ }
23
+
24
+ export async function recover(api:Transport,journal:Journal,repositories?:RepositoryWorkspace,dataRoot?:string):Promise<void> {
25
+ const state=await journal.load();
26
+ if(!state)return;
27
+ const result=await api.post("/executor/reconcile",{lease:state.lease,journal:{pending_event:state.pending_event??null}});
28
+ let terminalState=result.state;
29
+ if(result.action==="replay" && state.pending_event) {
30
+ const kind=state.pending_event.kind;
31
+ const acknowledgement=await api.post("/executor/events",{lease:state.lease,event:state.pending_event});
32
+ if(["succeeded","blocked","failed","cancelled"].includes(acknowledgement?.state))terminalState=acknowledgement.state;
33
+ if(["started","progress"].includes(kind))terminalState=(await api.post("/executor/reconcile",{lease:state.lease,journal:{pending_event:null}})).state;
34
+ } else if(result.action!=="acknowledge")throw new Error("Task reconciliation needs operator attention.");
35
+ if(state.workspace && repositories && dataRoot && ["succeeded","blocked","failed","cancelled"].includes(terminalState)) {
36
+ await repositories.markTerminal(state.workspace,terminalState==="succeeded"?"success":"failure");
37
+ }
38
+ await journal.clear();
39
+ }
40
+
41
+ export async function runLoop(config:Config,root:string,signal:AbortSignal):Promise<void> {
42
+ const api=new API(config.server,config.token);
43
+ const journal=new Journal(root);
44
+ const repositories=new RepositoryWorkspace();
45
+ await recover(api,journal,repositories,dirname(root));
46
+ const health=CodexSession.start(config.codexBinary??"codex",root);
47
+ let authenticated=false;
48
+ try {await health.initialize();authenticated=await health.authenticated();}finally{health.stop();}
49
+ await api.post("/executor/heartbeat",{protocol:1,codex_authenticated:authenticated,platform:process.platform});
50
+ if(!authenticated)throw new Error("Sign in to the official Codex CLI with your own ChatGPT account before starting this connector.");
51
+ while(!signal.aborted) {
52
+ try {
53
+ await recover(api,journal,repositories,dirname(root));
54
+ await api.post("/executor/heartbeat",{protocol:1,codex_authenticated:true,platform:process.platform});
55
+ const claimStarted=performance.now();
56
+ const lease=await api.post("/executor/claim",{});
57
+ if(lease)await execute(config,api,journal,{lease,sequence:lease.sequence??0},claimStarted,signal,{dataRoot:dirname(root)});
58
+ } catch(error) {
59
+ if(signal.aborted)break;
60
+ if(error instanceof APIError && error.status===401)throw new Error("Executor access was revoked. Pair again with an authorized owner.");
61
+ console.error("Control-plane operation interrupted. Preserved task state; reconnecting without rerunning unacknowledged work.");
62
+ }
63
+ await new Promise(resolve=>setTimeout(resolve,5_000));
64
+ }
65
+ }
66
+
67
+ export async function execute(config:Config,api:Transport,journal:Journal,state:JournalState,claimStarted:number,signal:AbortSignal,dependencies:ExecutionDependencies={}):Promise<void> {
68
+ const controller=new AbortController();
69
+ let session:CodexSession|undefined;
70
+ let phase="github_installation_required";
71
+ let deadline=claimStarted+Math.min(state.lease.expires_in_ms??90_000,90_000)-5_000;
72
+ const abort=()=>{controller.abort();session?.stop();};
73
+ let workspacePath:string|undefined;
74
+ signal.addEventListener("abort",abort,{once:true});
75
+ const safety=setInterval(()=>{if(performance.now()>=deadline)abort();},250);
76
+ const renewal=setInterval(async()=>{
77
+ const started=performance.now();
78
+ try {
79
+ const result=await api.post("/executor/renew",state.lease);
80
+ if(!controller.signal.aborted)deadline=started+Math.min(result.expires_in_ms,90_000)-5_000;
81
+ } catch(error) {
82
+ if(error instanceof APIError && [401,409].includes(error.status))abort();
83
+ // A transport failure does not extend the last acknowledged deadline.
84
+ }
85
+ },20_000);
86
+ const timeBudget=setTimeout(abort,20*60_000);
87
+ try {
88
+ await journal.save(state);
89
+ await emitDurably(api,journal,state,"started",{});
90
+ await emitDurably(api,journal,state,"progress",{stage:"repository_preparing"});
91
+ const dataRoot=dependencies.dataRoot??dirname(journal.root);
92
+ const repositories=dependencies.repositories??new RepositoryWorkspace();
93
+ await repositories.cleanupExpired(dataRoot);
94
+ const credential=await api.post("/executor/github-token",{lease:state.lease,purpose:"checkout"});
95
+ phase="workspace_setup_failed";
96
+ const workspace=await repositories.prepare(dataRoot,state.lease,credential);
97
+ workspacePath=workspace.path;
98
+ const snapshot=state.lease.snapshot??{};
99
+ const instructionsPath=await repositories.writeProjectInstructions(workspace.path,snapshot.instructions??"");
100
+ state.workspace=workspace.path;state.baseline=workspace.baseline;await journal.save(state);
101
+ if(controller.signal.aborted)throw new Error("Lease ended.");
102
+ await emitDurably(api,journal,state,"progress",{stage:"workspace_ready"});
103
+ phase="authentication_required";
104
+ session=CodexSession.start(config.codexBinary??"codex",workspace.path);
105
+ await session.initialize();
106
+ if(!await session.authenticated())throw new Error("Local account is not signed in.");
107
+ phase="execution_failed";
108
+ const prompt=[
109
+ "Implement this development task end to end in this isolated checkout.",
110
+ "Read and respect the repository's AGENTS.md and project-scoped skills. Do not change global skills or configuration.",
111
+ "Validate changes and create a Conventional Commit on the existing issue branch. Do not push, create a PR, merge, or deploy: the trusted connector handles publication after your turn.",
112
+ "Do not invent external access or credentials. If essential context is missing, report a real blocker. Network access is disabled in this evaluation sandbox.",
113
+ instructionsPath
114
+ ? "Project instructions: read and follow the file at "+instructionsPath+" before making changes."
115
+ : "Project instructions:\n"+String(snapshot.instructions??""),
116
+ "Task: "+String(state.lease.title??""),String(state.lease.prompt??"")
117
+ ].join("\n\n");
118
+ await session.run(
119
+ workspace.path,
120
+ prompt,
121
+ typeof snapshot.model==="string"?snapshot.model:undefined,
122
+ controller.signal,
123
+ stage=>emitDurably(api,journal,state,"progress",{stage})
124
+ );
125
+ session.stop();session=undefined;
126
+ phase="publication_failed";
127
+ await api.post("/executor/renew",state.lease);
128
+ const publicationContext={workspace:workspace.path,repository:String(snapshot.repository??""),baseBranch:String(snapshot.base_branch??""),branch:workspace.branch,baseline:workspace.baseline};
129
+ const publisher=dependencies.publisherFactory?.(publicationContext)??new Publisher(publicationContext);
130
+ const status=await publisher.status(controller.signal);
131
+ if(status.changed) {
132
+ await emitDurably(api,journal,state,"progress",{stage:"publishing"});
133
+ const publishCredential=await api.post("/executor/github-token",{lease:state.lease,purpose:"publish"});
134
+ const result=await publisher.publish(state.lease.title??"Development task",`## Summary\n\nCompleted by the customer-owned local executor.\n\n## Validation\n\nReview the committed changes and repository checks before merging.\n\nRun: ${state.lease.id}`,publishCredential,controller.signal);
135
+ if(controller.signal.aborted)throw new Error("Lease ended.");
136
+ await emitDurably(api,journal,state,"progress",{stage:"pr_verifying"});
137
+ const acknowledgement=await emitDurably(api,journal,state,"candidate",result);
138
+ if(acknowledgement?.state==="succeeded")await repositories.markTerminal(workspace.path,"success").catch(()=>{});
139
+ } else {
140
+ const acknowledgement=await emitDurably(api,journal,state,"no_changes",{});
141
+ if(acknowledgement?.state==="succeeded")await repositories.markTerminal(workspace.path,"success").catch(()=>{});
142
+ }
143
+ await journal.clear();
144
+ } catch(error) {
145
+ session?.stop();
146
+ if(controller.signal.aborted || state.pending_event)throw error;
147
+ if(error instanceof APIError && ["github_installation_required","github_permission_denied","base_branch_missing","github_unavailable","local_policy_denied"].includes(error.code))phase=error.code;
148
+ else if(error instanceof APIError && error.code==="provider_authentication")phase="github_permission_denied";
149
+ else if(error instanceof APIError && ["provider_rejected","provider_rate_limit","provider_unavailable"].includes(error.code))phase="github_unavailable";
150
+ else if(error instanceof Error && /Selected base branch does not resolve to a commit/i.test(error.message))phase="base_branch_missing";
151
+ else if(error instanceof Error && /Repository setup failed|worktree|managed repository cache|Task repository snapshot/i.test(error.message))phase="workspace_setup_failed";
152
+ if(error instanceof CodexBlockedError)phase="missing_context";
153
+ const fingerprint=error instanceof RepeatedFailureError?error.fingerprint:createHash("sha256").update(phase+":"+(error instanceof Error?error.message:"unknown")).digest("hex");
154
+ const kind=["authentication_required","local_policy_denied","github_installation_required","github_permission_denied","base_branch_missing","missing_context","workspace_setup_failed"].includes(phase)?"blocked":"failure";
155
+ const acknowledgement=await emitDurably(api,journal,state,kind,{category:phase,fingerprint,occurrences:error instanceof RepeatedFailureError?3:1});
156
+ if(workspacePath && ["blocked","cancelled","failed"].includes(acknowledgement?.state)) {
157
+ await (dependencies.repositories??new RepositoryWorkspace()).markTerminal(workspacePath,"failure").catch(()=>{});
158
+ }
159
+ await journal.clear();
160
+ } finally {
161
+ clearInterval(safety);clearInterval(renewal);clearTimeout(timeBudget);
162
+ signal.removeEventListener("abort",abort);session?.stop();
163
+ }
164
+ }
@@ -0,0 +1,2 @@
1
+ export const validBranch=(value:unknown):value is string=>typeof value==="string" && value.length<=200 && /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) && !value.includes("..") && !value.includes("//") && value.split("/").every(part=>part && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(".lock"));
2
+ export const validRepository=(value:unknown):value is string=>typeof value==="string" && value.length<=200 && /^[A-Za-z0-9][A-Za-z0-9_-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes("..") && !value.endsWith(".git");