dev-orchestrator-connector 0.1.0 → 0.1.1

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/dist/worker.js ADDED
@@ -0,0 +1,192 @@
1
+ import { createHash } from "node:crypto";
2
+ import { dirname } from "node:path";
3
+ import { Journal } from "./journal.js";
4
+ import { API, APIError } from "./api.js";
5
+ import { CodexSession, RepeatedFailureError, CodexBlockedError } from "./codex.js";
6
+ import { RepositoryWorkspace } from "./repository.js";
7
+ import { Publisher } from "./publisher.js";
8
+ export async function emitDurably(api, journal, state, kind, data) {
9
+ if (state.pending_event)
10
+ throw new Error("A previous event still needs acknowledgement.");
11
+ state.sequence++;
12
+ state.pending_event = { sequence: state.sequence, kind, data };
13
+ await journal.save(state);
14
+ const result = await api.post("/executor/events", { lease: state.lease, event: state.pending_event });
15
+ delete state.pending_event;
16
+ await journal.save(state);
17
+ return result;
18
+ }
19
+ export async function recover(api, journal, repositories, dataRoot) {
20
+ const state = await journal.load();
21
+ if (!state)
22
+ return;
23
+ const result = await api.post("/executor/reconcile", { lease: state.lease, journal: { pending_event: state.pending_event ?? null } });
24
+ let terminalState = result.state;
25
+ if (result.action === "replay" && state.pending_event) {
26
+ const kind = state.pending_event.kind;
27
+ const acknowledgement = await api.post("/executor/events", { lease: state.lease, event: state.pending_event });
28
+ if (["succeeded", "blocked", "failed", "cancelled"].includes(acknowledgement?.state))
29
+ terminalState = acknowledgement.state;
30
+ if (["started", "progress"].includes(kind))
31
+ terminalState = (await api.post("/executor/reconcile", { lease: state.lease, journal: { pending_event: null } })).state;
32
+ }
33
+ else if (result.action !== "acknowledge")
34
+ 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
+ export async function runLoop(config, root, signal) {
41
+ const api = new API(config.server, config.token);
42
+ const journal = new Journal(root);
43
+ const repositories = new RepositoryWorkspace();
44
+ await recover(api, journal, repositories, dirname(root));
45
+ const health = CodexSession.start(config.codexBinary ?? "codex", root);
46
+ let authenticated = false;
47
+ try {
48
+ await health.initialize();
49
+ authenticated = await health.authenticated();
50
+ }
51
+ finally {
52
+ health.stop();
53
+ }
54
+ await api.post("/executor/heartbeat", { protocol: 1, codex_authenticated: authenticated, platform: process.platform });
55
+ if (!authenticated)
56
+ throw new Error("Sign in to the official Codex CLI with your own ChatGPT account before starting this connector.");
57
+ while (!signal.aborted) {
58
+ try {
59
+ await recover(api, journal, repositories, dirname(root));
60
+ await api.post("/executor/heartbeat", { protocol: 1, codex_authenticated: true, platform: process.platform });
61
+ const claimStarted = performance.now();
62
+ const lease = await api.post("/executor/claim", {});
63
+ if (lease)
64
+ await execute(config, api, journal, { lease, sequence: lease.sequence ?? 0 }, claimStarted, signal, { dataRoot: dirname(root) });
65
+ }
66
+ catch (error) {
67
+ if (signal.aborted)
68
+ break;
69
+ if (error instanceof APIError && error.status === 401)
70
+ throw new Error("Executor access was revoked. Pair again with an authorized owner.");
71
+ console.error("Control-plane operation interrupted. Preserved task state; reconnecting without rerunning unacknowledged work.");
72
+ }
73
+ await new Promise(resolve => setTimeout(resolve, 5_000));
74
+ }
75
+ }
76
+ export async function execute(config, api, journal, state, claimStarted, signal, dependencies = {}) {
77
+ const controller = new AbortController();
78
+ let session;
79
+ let phase = "github_installation_required";
80
+ let deadline = claimStarted + Math.min(state.lease.expires_in_ms ?? 90_000, 90_000) - 5_000;
81
+ const abort = () => { controller.abort(); session?.stop(); };
82
+ let workspacePath;
83
+ signal.addEventListener("abort", abort, { once: true });
84
+ const safety = setInterval(() => { if (performance.now() >= deadline)
85
+ abort(); }, 250);
86
+ const renewal = setInterval(async () => {
87
+ const started = performance.now();
88
+ try {
89
+ const result = await api.post("/executor/renew", state.lease);
90
+ if (!controller.signal.aborted)
91
+ deadline = started + Math.min(result.expires_in_ms, 90_000) - 5_000;
92
+ }
93
+ catch (error) {
94
+ if (error instanceof APIError && [401, 409].includes(error.status))
95
+ abort();
96
+ // A transport failure does not extend the last acknowledged deadline.
97
+ }
98
+ }, 20_000);
99
+ const timeBudget = setTimeout(abort, 20 * 60_000);
100
+ try {
101
+ await journal.save(state);
102
+ await emitDurably(api, journal, state, "started", {});
103
+ await emitDurably(api, journal, state, "progress", { stage: "repository_preparing" });
104
+ const dataRoot = dependencies.dataRoot ?? dirname(journal.root);
105
+ const repositories = dependencies.repositories ?? new RepositoryWorkspace();
106
+ await repositories.cleanupExpired(dataRoot);
107
+ const credential = await api.post("/executor/github-token", { lease: state.lease, purpose: "checkout" });
108
+ phase = "workspace_setup_failed";
109
+ const workspace = await repositories.prepare(dataRoot, state.lease, credential);
110
+ workspacePath = workspace.path;
111
+ const snapshot = state.lease.snapshot ?? {};
112
+ const instructionsPath = await repositories.writeProjectInstructions(workspace.path, snapshot.instructions ?? "");
113
+ state.workspace = workspace.path;
114
+ state.baseline = workspace.baseline;
115
+ await journal.save(state);
116
+ if (controller.signal.aborted)
117
+ throw new Error("Lease ended.");
118
+ await emitDurably(api, journal, state, "progress", { stage: "workspace_ready" });
119
+ phase = "authentication_required";
120
+ session = CodexSession.start(config.codexBinary ?? "codex", workspace.path);
121
+ await session.initialize();
122
+ if (!await session.authenticated())
123
+ throw new Error("Local account is not signed in.");
124
+ phase = "execution_failed";
125
+ const prompt = [
126
+ "Implement this development task end to end in this isolated checkout.",
127
+ "Read and respect the repository's AGENTS.md and project-scoped skills. Do not change global skills or configuration.",
128
+ "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.",
129
+ "Do not invent external access or credentials. If essential context is missing, report a real blocker. Network access is disabled in this evaluation sandbox.",
130
+ instructionsPath
131
+ ? "Project instructions: read and follow the file at " + instructionsPath + " before making changes."
132
+ : "Project instructions:\n" + String(snapshot.instructions ?? ""),
133
+ "Task: " + String(state.lease.title ?? ""), String(state.lease.prompt ?? "")
134
+ ].join("\n\n");
135
+ await session.run(workspace.path, prompt, typeof snapshot.model === "string" ? snapshot.model : undefined, controller.signal, stage => emitDurably(api, journal, state, "progress", { stage }));
136
+ session.stop();
137
+ session = undefined;
138
+ phase = "publication_failed";
139
+ await api.post("/executor/renew", state.lease);
140
+ const publicationContext = { workspace: workspace.path, repository: String(snapshot.repository ?? ""), baseBranch: String(snapshot.base_branch ?? ""), branch: workspace.branch, baseline: workspace.baseline };
141
+ const publisher = dependencies.publisherFactory?.(publicationContext) ?? new Publisher(publicationContext);
142
+ const status = await publisher.status(controller.signal);
143
+ if (status.changed) {
144
+ await emitDurably(api, journal, state, "progress", { stage: "publishing" });
145
+ const publishCredential = await api.post("/executor/github-token", { lease: state.lease, purpose: "publish" });
146
+ 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);
147
+ if (controller.signal.aborted)
148
+ throw new Error("Lease ended.");
149
+ await emitDurably(api, journal, state, "progress", { stage: "pr_verifying" });
150
+ const acknowledgement = await emitDurably(api, journal, state, "candidate", result);
151
+ if (acknowledgement?.state === "succeeded")
152
+ await repositories.markTerminal(workspace.path, "success").catch(() => { });
153
+ }
154
+ else {
155
+ const acknowledgement = await emitDurably(api, journal, state, "no_changes", {});
156
+ if (acknowledgement?.state === "succeeded")
157
+ await repositories.markTerminal(workspace.path, "success").catch(() => { });
158
+ }
159
+ await journal.clear();
160
+ }
161
+ catch (error) {
162
+ session?.stop();
163
+ if (controller.signal.aborted || state.pending_event)
164
+ throw error;
165
+ if (error instanceof APIError && ["github_installation_required", "github_permission_denied", "base_branch_missing", "github_unavailable", "local_policy_denied"].includes(error.code))
166
+ phase = error.code;
167
+ else if (error instanceof APIError && error.code === "provider_authentication")
168
+ phase = "github_permission_denied";
169
+ else if (error instanceof APIError && ["provider_rejected", "provider_rate_limit", "provider_unavailable"].includes(error.code))
170
+ phase = "github_unavailable";
171
+ else if (error instanceof Error && /Selected base branch does not resolve to a commit/i.test(error.message))
172
+ phase = "base_branch_missing";
173
+ else if (error instanceof Error && /Repository setup failed|worktree|managed repository cache|Task repository snapshot/i.test(error.message))
174
+ phase = "workspace_setup_failed";
175
+ if (error instanceof CodexBlockedError)
176
+ phase = "missing_context";
177
+ const fingerprint = error instanceof RepeatedFailureError ? error.fingerprint : createHash("sha256").update(phase + ":" + (error instanceof Error ? error.message : "unknown")).digest("hex");
178
+ 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";
179
+ const acknowledgement = await emitDurably(api, journal, state, kind, { category: phase, fingerprint, occurrences: error instanceof RepeatedFailureError ? 3 : 1 });
180
+ if (workspacePath && ["blocked", "cancelled", "failed"].includes(acknowledgement?.state)) {
181
+ await (dependencies.repositories ?? new RepositoryWorkspace()).markTerminal(workspacePath, "failure").catch(() => { });
182
+ }
183
+ await journal.clear();
184
+ }
185
+ finally {
186
+ clearInterval(safety);
187
+ clearInterval(renewal);
188
+ clearTimeout(timeBudget);
189
+ signal.removeEventListener("abort", abort);
190
+ session?.stop();
191
+ }
192
+ }
@@ -0,0 +1,2 @@
1
+ export const validBranch = (value) => 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) => 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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-orchestrator-connector",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Run a customer-owned local Codex executor for Dev Orchestrator.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,13 +8,20 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "src/"
11
+ "dist/"
12
12
  ],
13
- "engines": { "node": ">=22.18.0" },
13
+ "engines": {
14
+ "node": ">=22.18.0"
15
+ },
14
16
  "scripts": {
15
- "test": "node --experimental-strip-types --test test/*.test.ts",
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "prepack": "npm run build",
19
+ "test": "npm run build && node --experimental-strip-types --test test/*.test.ts",
16
20
  "check": "tsc --noEmit",
17
- "start": "node --experimental-strip-types src/cli.ts"
21
+ "start": "npm run build && node dist/cli.js"
18
22
  },
19
- "devDependencies": { "@types/node": "22.18.8", "typescript": "5.9.3" }
23
+ "devDependencies": {
24
+ "@types/node": "22.18.8",
25
+ "typescript": "5.9.3"
26
+ }
20
27
  }
package/src/activity.ts DELETED
@@ -1,33 +0,0 @@
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 DELETED
@@ -1,23 +0,0 @@
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 DELETED
@@ -1,55 +0,0 @@
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 DELETED
@@ -1,160 +0,0 @@
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 DELETED
@@ -1,46 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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 DELETED
@@ -1,36 +0,0 @@
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
- }