jules-orchestrator-kit 0.29.0 → 0.31.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 +174 -89
- package/bin/agentctl.mjs +205 -6
- package/index.mjs +21 -1
- package/package.json +1 -1
- package/src/config.mjs +18 -2
- package/src/dag-engine.mjs +76 -0
- package/src/dashboard.mjs +2 -2
- package/src/engine.mjs +296 -37
- package/src/envelope.mjs +24 -2
- package/src/mcp.mjs +35 -1
- package/src/ops/checkpoint.mjs +167 -0
- package/src/ops/command-registry.mjs +274 -0
- package/src/ops/doctor-planner.mjs +214 -0
- package/src/ops/doctor-registry.mjs +442 -0
- package/src/ops/ide-scaffold.mjs +139 -0
- package/src/ops/receipts.mjs +171 -0
- package/src/ops/swarm-actions.mjs +156 -0
- package/src/ops/task-actions.mjs +214 -0
- package/src/ops/tdd-generator.mjs +115 -0
- package/src/ops/transaction.mjs +278 -0
- package/src/provider.mjs +164 -8
- package/src/security.mjs +15 -7
- package/src/stack-detector.mjs +15 -5
- package/src/state.mjs +44 -7
- package/src/task-optimizer.mjs +306 -0
- package/src/telemetry.mjs +35 -2
- package/src/tui.mjs +4 -1
- package/src/ux/capabilities.mjs +180 -0
- package/src/ux/diff-viewer.mjs +289 -0
- package/src/ux/key-decoder.mjs +281 -0
- package/src/ux/layout.mjs +195 -0
- package/src/ux/log-viewer.mjs +110 -0
- package/src/ux/palette.mjs +179 -0
- package/src/ux/queue-model.mjs +206 -0
- package/src/ux/renderer.mjs +188 -0
- package/src/ux/swarm-model.mjs +146 -0
- package/src/ux/terminal-session.mjs +215 -0
- package/src/ux/widgets.mjs +323 -0
- package/src/webhook.mjs +120 -0
- package/src/wizard-init.mjs +41 -11
- package/src/wizard-task.mjs +51 -15
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
openSync,
|
|
8
|
+
writeSync,
|
|
9
|
+
fsyncSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join, dirname, resolve, relative, isAbsolute } from "node:path";
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { createOperationReceipt } from "./receipts.mjs";
|
|
16
|
+
import { withVfsMutex } from "../state.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {import("../ux/capabilities.mjs").TerminalSessionOptions} TerminalSessionOptions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Calculate file SHA-256 hash.
|
|
24
|
+
* @param {string} filePath
|
|
25
|
+
* @returns {string | null}
|
|
26
|
+
*/
|
|
27
|
+
function getFileHash(filePath) {
|
|
28
|
+
if (!existsSync(filePath)) return null;
|
|
29
|
+
try {
|
|
30
|
+
const data = readFileSync(filePath);
|
|
31
|
+
return "sha256:" + createHash("sha256").update(data).digest("hex");
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveWithinRoot(root, candidate, label) {
|
|
38
|
+
if (typeof candidate !== "string" || !candidate || candidate.includes("\0")) {
|
|
39
|
+
throw new Error(`Invalid ${label} path`);
|
|
40
|
+
}
|
|
41
|
+
const targetPath = resolve(root, candidate);
|
|
42
|
+
const rel = relative(resolve(root), targetPath);
|
|
43
|
+
if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
|
|
44
|
+
throw new Error(`${label} path escapes repository root: ${candidate}`);
|
|
45
|
+
}
|
|
46
|
+
return targetPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function atomicWriteFile(filePath, content) {
|
|
50
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
51
|
+
let fd;
|
|
52
|
+
try {
|
|
53
|
+
fd = openSync(tmpPath, "wx", 0o600);
|
|
54
|
+
writeSync(fd, content, "utf-8");
|
|
55
|
+
fsyncSync(fd);
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
fd = undefined;
|
|
58
|
+
renameSync(tmpPath, filePath);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (fd !== undefined) {
|
|
61
|
+
try { closeSync(fd); } catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
try { unlinkSync(tmpPath); } catch (_) {}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function throwIfAborted(signal) {
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
throw signal.reason || new Error("Action plan application aborted");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Verify preconditions of an ActionPlan against target repository state.
|
|
76
|
+
* @param {import("../ux/capabilities.mjs").ActionPlan} plan
|
|
77
|
+
* @param {string} root
|
|
78
|
+
*/
|
|
79
|
+
export function verifyPreconditions(plan, root) {
|
|
80
|
+
for (const cond of plan.preconditions || []) {
|
|
81
|
+
if (cond.kind === "file-hash") {
|
|
82
|
+
const targetPath = resolveWithinRoot(root, cond.target, "Precondition target");
|
|
83
|
+
const actualHash = getFileHash(targetPath);
|
|
84
|
+
if (actualHash !== cond.expected) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Precondition failed [file-hash]: ${cond.target} expected ${cond.expected}, received ${actualHash}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
} else if (cond.kind === "lock-free") {
|
|
90
|
+
const lockTarget = String(cond.target || "");
|
|
91
|
+
const lockBase = resolveWithinRoot(root, join(".agent", "state", "locks", lockTarget), "Lock target");
|
|
92
|
+
const lockPaths = [`${lockBase}.json`, `${lockBase}.lock`];
|
|
93
|
+
if (lockPaths.some((lockPath) => existsSync(lockPath))) {
|
|
94
|
+
throw new Error(`Precondition failed [lock-free]: Lock ${cond.target} is active.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Execute an ActionPlan within a transactional boundaries (atomic apply + rollback).
|
|
102
|
+
* @param {any} plan
|
|
103
|
+
* @param {Object} options
|
|
104
|
+
* @param {string} options.root
|
|
105
|
+
* @param {AbortSignal} [options.signal]
|
|
106
|
+
* @returns {Promise<import("./receipts.mjs").OperationReceipt>}
|
|
107
|
+
*/
|
|
108
|
+
export async function applyActionPlan(plan, options = {}) {
|
|
109
|
+
const root = resolve(options.root || process.cwd());
|
|
110
|
+
const mutexDir = join(root, ".agent", "state", ".action-plan.mutex");
|
|
111
|
+
mkdirSync(dirname(mutexDir), { recursive: true });
|
|
112
|
+
return withVfsMutex(mutexDir, () => applyActionPlanLocked(plan, { ...options, root }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function applyActionPlanLocked(plan, options) {
|
|
116
|
+
const root = options.root;
|
|
117
|
+
const startTime = Date.now();
|
|
118
|
+
throwIfAborted(options.signal);
|
|
119
|
+
|
|
120
|
+
// 1. Verify Preconditions
|
|
121
|
+
verifyPreconditions(plan, root);
|
|
122
|
+
|
|
123
|
+
// 2. Prepare Intent Journaling
|
|
124
|
+
if (!plan || typeof plan.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plan.id)) {
|
|
125
|
+
throw new Error("Invalid action plan id");
|
|
126
|
+
}
|
|
127
|
+
const journalDir = join(root, ".agent", "state", "journal");
|
|
128
|
+
if (!existsSync(journalDir)) {
|
|
129
|
+
mkdirSync(journalDir, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
const journalPath = join(journalDir, `${plan.id}.intent.json`);
|
|
132
|
+
atomicWriteFile(journalPath, JSON.stringify({ plan, startedAt: new Date().toISOString() }, null, 2));
|
|
133
|
+
|
|
134
|
+
/** @type {Array<{ path: string, originalContent: string | null, operation?: string, fromPath?: string, sourceContent?: string }>} */
|
|
135
|
+
const rollbackStack = [];
|
|
136
|
+
const mutationsApplied = [];
|
|
137
|
+
const effectsExecuted = [];
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// 3. Apply File Mutations
|
|
141
|
+
for (const mutation of plan.fileMutations || []) {
|
|
142
|
+
throwIfAborted(options.signal);
|
|
143
|
+
const targetPath = resolveWithinRoot(root, mutation.path, "Mutation target");
|
|
144
|
+
|
|
145
|
+
if (mutation.operation === "move") {
|
|
146
|
+
const sourcePath = resolveWithinRoot(root, mutation.fromPath, "Move source");
|
|
147
|
+
if (sourcePath === targetPath) {
|
|
148
|
+
throw new Error(`Move source and target are identical: ${mutation.path}`);
|
|
149
|
+
}
|
|
150
|
+
if (!existsSync(sourcePath)) {
|
|
151
|
+
throw new Error(`Move source does not exist: ${mutation.fromPath}`);
|
|
152
|
+
}
|
|
153
|
+
if (existsSync(targetPath)) {
|
|
154
|
+
throw new Error(`Move target already exists: ${mutation.path}`);
|
|
155
|
+
}
|
|
156
|
+
const sourceContent = readFileSync(sourcePath, "utf-8");
|
|
157
|
+
const sourceHash = "sha256:" + createHash("sha256").update(sourceContent).digest("hex");
|
|
158
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
159
|
+
rollbackStack.push({
|
|
160
|
+
path: targetPath,
|
|
161
|
+
originalContent: null,
|
|
162
|
+
operation: "move",
|
|
163
|
+
fromPath: sourcePath,
|
|
164
|
+
sourceContent,
|
|
165
|
+
});
|
|
166
|
+
renameSync(sourcePath, targetPath);
|
|
167
|
+
mutationsApplied.push({ path: mutation.path, operation: "move", hashBefore: sourceHash });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!["delete", "create", "replace"].includes(mutation.operation)) {
|
|
172
|
+
throw new Error(`Unsupported file mutation operation: ${mutation.operation}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const parentDir = dirname(targetPath);
|
|
176
|
+
if (!existsSync(parentDir)) {
|
|
177
|
+
mkdirSync(parentDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const existingContent = existsSync(targetPath) ? readFileSync(targetPath, "utf-8") : null;
|
|
181
|
+
const hashBefore = existingContent !== null
|
|
182
|
+
? "sha256:" + createHash("sha256").update(existingContent).digest("hex")
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
if (mutation.operation === "create" && existingContent !== null) {
|
|
186
|
+
throw new Error(`Create target already exists: ${mutation.path}`);
|
|
187
|
+
}
|
|
188
|
+
rollbackStack.push({ path: targetPath, originalContent: existingContent, operation: mutation.operation });
|
|
189
|
+
|
|
190
|
+
if (mutation.operation === "delete") {
|
|
191
|
+
if (existsSync(targetPath)) {
|
|
192
|
+
unlinkSync(targetPath);
|
|
193
|
+
}
|
|
194
|
+
mutationsApplied.push({ path: mutation.path, operation: "delete", hashBefore });
|
|
195
|
+
} else {
|
|
196
|
+
const newContent = mutation.newContent ?? "";
|
|
197
|
+
atomicWriteFile(targetPath, newContent);
|
|
198
|
+
|
|
199
|
+
const hashAfter = "sha256:" + createHash("sha256").update(newContent).digest("hex");
|
|
200
|
+
mutationsApplied.push({ path: mutation.path, operation: mutation.operation, hashBefore, hashAfter });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4. Execute Command Effects
|
|
205
|
+
for (const effect of plan.commandEffects || []) {
|
|
206
|
+
throwIfAborted(options.signal);
|
|
207
|
+
const execPath = effect.executable;
|
|
208
|
+
const args = Array.isArray(effect.args) ? effect.args : [];
|
|
209
|
+
const cwd = effect.cwd ? resolveWithinRoot(root, effect.cwd, "Command working directory") : root;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
execFileSync(execPath, args, {
|
|
213
|
+
cwd,
|
|
214
|
+
timeout: effect.timeoutMs || 30000,
|
|
215
|
+
env: process.env,
|
|
216
|
+
});
|
|
217
|
+
effectsExecuted.push({ executable: execPath, args, exitCode: 0 });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const exitCode = typeof err.status === "number" ? err.status : 1;
|
|
220
|
+
effectsExecuted.push({ executable: execPath, args, exitCode });
|
|
221
|
+
throw new Error(`Command effect failed (${execPath} ${args.join(" ")}): exit code ${exitCode}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 5. Clean up Journal & Record Success Receipt
|
|
226
|
+
if (existsSync(journalPath)) {
|
|
227
|
+
unlinkSync(journalPath);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const receipt = createOperationReceipt(root, {
|
|
231
|
+
actionPlanId: plan.id,
|
|
232
|
+
kind: plan.kind,
|
|
233
|
+
title: plan.title,
|
|
234
|
+
repository: plan.repository || "local",
|
|
235
|
+
status: "success",
|
|
236
|
+
durationMs: Date.now() - startTime,
|
|
237
|
+
mutationsApplied,
|
|
238
|
+
effectsExecuted,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return receipt;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Execute Rollback Stack in reverse order
|
|
244
|
+
for (let i = rollbackStack.length - 1; i >= 0; i--) {
|
|
245
|
+
const mutation = rollbackStack[i];
|
|
246
|
+
try {
|
|
247
|
+
if (mutation.operation === "move") {
|
|
248
|
+
if (existsSync(mutation.path) && !existsSync(mutation.fromPath)) {
|
|
249
|
+
renameSync(mutation.path, mutation.fromPath);
|
|
250
|
+
}
|
|
251
|
+
} else if (mutation.originalContent === null) {
|
|
252
|
+
if (existsSync(mutation.path)) unlinkSync(mutation.path);
|
|
253
|
+
} else {
|
|
254
|
+
atomicWriteFile(mutation.path, mutation.originalContent);
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Best-effort rollback
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (existsSync(journalPath)) {
|
|
262
|
+
try { unlinkSync(journalPath); } catch (_) {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const failedReceipt = createOperationReceipt(root, {
|
|
266
|
+
actionPlanId: plan.id,
|
|
267
|
+
kind: plan.kind,
|
|
268
|
+
title: plan.title,
|
|
269
|
+
repository: plan.repository || "local",
|
|
270
|
+
status: "rolled-back",
|
|
271
|
+
durationMs: Date.now() - startTime,
|
|
272
|
+
mutationsApplied,
|
|
273
|
+
effectsExecuted,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
throw new Error(`Action plan execution failed and was rolled back: ${err.message}. Receipt: ${failedReceipt.receiptId}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/provider.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { redactSecrets } from "./security.mjs";
|
|
2
3
|
|
|
3
4
|
export const JULES_PRESET = {
|
|
4
5
|
name: "jules",
|
|
@@ -73,7 +74,7 @@ export class ProviderSchemaError extends Error {
|
|
|
73
74
|
export function parseRetryAfter(header) {
|
|
74
75
|
if (!header) return null;
|
|
75
76
|
const seconds = Number(header);
|
|
76
|
-
if (
|
|
77
|
+
if (Number.isFinite(seconds)) {
|
|
77
78
|
return Math.max(0, Math.round(seconds * 1000));
|
|
78
79
|
}
|
|
79
80
|
const dateMs = Date.parse(header);
|
|
@@ -110,12 +111,20 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
110
111
|
}
|
|
111
112
|
const providerSpec = typeof spec === "string"
|
|
112
113
|
? (spec === "claude-code" ? CLAUDE_PRESET : spec === "codex" ? CODEX_PRESET : JULES_PRESET)
|
|
113
|
-
: spec;
|
|
114
|
+
: spec || JULES_PRESET;
|
|
115
|
+
|
|
116
|
+
if (!providerSpec || typeof providerSpec !== "object") {
|
|
117
|
+
throw new TypeError("Provider specification must be a string or object");
|
|
118
|
+
}
|
|
114
119
|
|
|
115
120
|
return {
|
|
116
121
|
name: providerSpec.name || "custom-provider",
|
|
117
122
|
|
|
118
|
-
async dispatch(task, ctx = {}) {
|
|
123
|
+
async dispatch(task = {}, ctx = {}) {
|
|
124
|
+
if (!task || typeof task !== "object") {
|
|
125
|
+
throw new TypeError("Provider task must be an object");
|
|
126
|
+
}
|
|
127
|
+
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
119
128
|
const rawToken = process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
|
|
120
129
|
if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
|
|
121
130
|
throw new MissingApiKeyError();
|
|
@@ -195,7 +204,8 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
195
204
|
body = JSON.stringify(bodyObj);
|
|
196
205
|
}
|
|
197
206
|
|
|
198
|
-
const
|
|
207
|
+
const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 120_000);
|
|
208
|
+
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 120_000;
|
|
199
209
|
let res;
|
|
200
210
|
try {
|
|
201
211
|
res = await fetch(url, {
|
|
@@ -216,7 +226,7 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
216
226
|
if (!res.ok) {
|
|
217
227
|
const text = await res.text();
|
|
218
228
|
const cleanText = text.slice(0, 500);
|
|
219
|
-
const sanitizedText = rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText;
|
|
229
|
+
const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
|
|
220
230
|
const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
221
231
|
const retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
222
232
|
|
|
@@ -282,7 +292,7 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
282
292
|
throw new Error(`Provider Exec Failed: ${res.error.message}`);
|
|
283
293
|
}
|
|
284
294
|
if (res.status !== 0) {
|
|
285
|
-
const stderr = (res.stderr || "").slice(0, 500);
|
|
295
|
+
const stderr = redactSecrets((res.stderr || "").slice(0, 500));
|
|
286
296
|
throw new Error(`Provider Exec Exit ${res.status}: ${stderr}`);
|
|
287
297
|
}
|
|
288
298
|
|
|
@@ -301,6 +311,124 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
301
311
|
if (providerSpec.type === "exec" && !providerSpec.command) return false;
|
|
302
312
|
return true;
|
|
303
313
|
},
|
|
314
|
+
|
|
315
|
+
async resume(sessionId, prompt = "", ctx = {}, task = null) {
|
|
316
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
317
|
+
throw new TypeError("resume() requires a valid sessionId string");
|
|
318
|
+
}
|
|
319
|
+
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
320
|
+
|
|
321
|
+
const rawToken = process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
|
|
322
|
+
if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
|
|
323
|
+
throw new MissingApiKeyError();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (ctx.dryRun) {
|
|
327
|
+
return {
|
|
328
|
+
id: sessionId,
|
|
329
|
+
status: "pending",
|
|
330
|
+
resumed: true,
|
|
331
|
+
provider: providerSpec.name,
|
|
332
|
+
prompt: redactSecrets(prompt),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (providerSpec.type === "http") {
|
|
337
|
+
const sendMessageUrlTemplate = providerSpec.sendMessageUrl || `${providerSpec.url}/${sessionId}:sendMessage`;
|
|
338
|
+
const urlData = { sessionId, token: rawToken, ...ctx };
|
|
339
|
+
const url = interpolateString(sendMessageUrlTemplate, urlData);
|
|
340
|
+
|
|
341
|
+
const headers = {};
|
|
342
|
+
for (const [k, v] of Object.entries(providerSpec.headers || {})) {
|
|
343
|
+
const val = interpolateString(v, urlData);
|
|
344
|
+
if (val.includes("\r") || val.includes("\n")) {
|
|
345
|
+
throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
|
|
346
|
+
}
|
|
347
|
+
headers[k] = val;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const bodyObj = { prompt: prompt };
|
|
351
|
+
const body = JSON.stringify(bodyObj);
|
|
352
|
+
|
|
353
|
+
const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 120_000);
|
|
354
|
+
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 120_000;
|
|
355
|
+
let res;
|
|
356
|
+
try {
|
|
357
|
+
res = await fetch(url, {
|
|
358
|
+
method: "POST",
|
|
359
|
+
headers,
|
|
360
|
+
body,
|
|
361
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
|
|
365
|
+
throw new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, {
|
|
366
|
+
status: 504,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
const text = await res.text();
|
|
374
|
+
const cleanText = text.slice(0, 500);
|
|
375
|
+
const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
|
|
376
|
+
|
|
377
|
+
// Fail-soft fallback: if remote session is closed/expired (400 or 404) and task object is available, fall back to cold dispatch
|
|
378
|
+
if ((res.status === 400 || res.status === 404) && task && typeof task === "object") {
|
|
379
|
+
const fallbackRes = await this.dispatch(task, { ...ctx, warmResumptionFailed: true });
|
|
380
|
+
return {
|
|
381
|
+
...fallbackRes,
|
|
382
|
+
_warmFallback: true,
|
|
383
|
+
_warmErrorStatus: res.status,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
|
|
388
|
+
const retryAfterMs = parseRetryAfter(retryAfterHeader);
|
|
389
|
+
|
|
390
|
+
if (res.status === 429) {
|
|
391
|
+
throw new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
|
|
392
|
+
retryAfterMs: retryAfterMs ?? 60000,
|
|
393
|
+
status: 429,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (res.status >= 500 && res.status < 600) {
|
|
398
|
+
throw new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
|
|
399
|
+
status: res.status,
|
|
400
|
+
retryAfterMs: retryAfterMs ?? undefined,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
|
|
405
|
+
err.status = res.status;
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let json;
|
|
410
|
+
try {
|
|
411
|
+
json = await res.json();
|
|
412
|
+
} catch (err) {
|
|
413
|
+
throw new ProviderSchemaError(`Provider Payload Error: Invalid JSON response: ${err.message}`, {
|
|
414
|
+
status: res.status,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
id: json.id || json.name || sessionId,
|
|
420
|
+
status: json.state || "active",
|
|
421
|
+
resumed: true,
|
|
422
|
+
raw: json,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (providerSpec.type === "exec") {
|
|
427
|
+
return this.dispatch({ ...(task || {}), prompt }, ctx);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
431
|
+
},
|
|
304
432
|
};
|
|
305
433
|
}
|
|
306
434
|
|
|
@@ -332,7 +460,35 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
332
460
|
err.status === 429;
|
|
333
461
|
|
|
334
462
|
if (i === providerList.length - 1 || !isRecoverable) {
|
|
335
|
-
err
|
|
463
|
+
if (err && typeof err === "object") {
|
|
464
|
+
err._failoverErrors = errors;
|
|
465
|
+
}
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
async resume(sessionId, prompt, ctx = {}, task = null) {
|
|
473
|
+
const errors = [];
|
|
474
|
+
for (let i = 0; i < providerList.length; i++) {
|
|
475
|
+
const provider = providerList[i];
|
|
476
|
+
if (typeof provider.resume !== "function") continue;
|
|
477
|
+
try {
|
|
478
|
+
const res = await provider.resume(sessionId, prompt, ctx, task);
|
|
479
|
+
return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
|
|
480
|
+
} catch (err) {
|
|
481
|
+
errors.push({ provider: provider.name, error: err });
|
|
482
|
+
const isRecoverable =
|
|
483
|
+
err instanceof ProviderRateLimitError ||
|
|
484
|
+
err instanceof ProviderUnavailableError ||
|
|
485
|
+
(err.status && err.status >= 500 && err.status < 600) ||
|
|
486
|
+
err.status === 429;
|
|
487
|
+
|
|
488
|
+
if (i === providerList.length - 1 || !isRecoverable) {
|
|
489
|
+
if (err && typeof err === "object") {
|
|
490
|
+
err._failoverErrors = errors;
|
|
491
|
+
}
|
|
336
492
|
throw err;
|
|
337
493
|
}
|
|
338
494
|
}
|
|
@@ -340,7 +496,7 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
340
496
|
},
|
|
341
497
|
|
|
342
498
|
validate() {
|
|
343
|
-
return providerList.every((p) => p.validate());
|
|
499
|
+
return providerList.every((p) => typeof p.validate !== "function" || p.validate());
|
|
344
500
|
},
|
|
345
501
|
};
|
|
346
502
|
}
|
package/src/security.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync } from "node:fs";
|
|
1
|
+
import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join, basename } from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { normalizePath } from "./config.mjs";
|
|
@@ -13,7 +13,7 @@ export const HIGH_CONFIDENCE_PATTERNS = [
|
|
|
13
13
|
|
|
14
14
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
15
15
|
/\bASIA[0-9A-Z]{16}\b/g,
|
|
16
|
-
/\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/
|
|
16
|
+
/\baws_secret_access_key\s*=\s*['"]?[A-Za-z0-9\/+=]{40}['"]?/gi,
|
|
17
17
|
|
|
18
18
|
/-----BEGIN (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----[\s\S]*?-----END (?:RSA|DSA|EC|OPENSSH|PRIVATE)(?:\s+PRIVATE)? KEY-----/g,
|
|
19
19
|
/PuTTY-User-Key-File-[0-9]:[^\n]+/g,
|
|
@@ -61,18 +61,26 @@ export function safeAtomicWrite(filePath, content, options = {}) {
|
|
|
61
61
|
const dir = dirname(filePath);
|
|
62
62
|
const tempFile = join(dir, `.tmp-${basename(filePath)}-${randomBytes(6).toString("hex")}`);
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
let fd;
|
|
65
65
|
try {
|
|
66
|
+
fd = openSync(tempFile, "wx", mode);
|
|
66
67
|
writeSync(fd, content, null, encoding);
|
|
67
68
|
if (options.sync !== false) {
|
|
68
69
|
fsyncSync(fd);
|
|
69
70
|
}
|
|
70
|
-
} finally {
|
|
71
71
|
closeSync(fd);
|
|
72
|
+
fd = undefined;
|
|
73
|
+
renameSync(tempFile, filePath);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (fd !== undefined) {
|
|
77
|
+
try { closeSync(fd); } catch (_) {}
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(tempFile)) unlinkSync(tempFile);
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
throw err;
|
|
72
83
|
}
|
|
73
|
-
|
|
74
|
-
renameSync(tempFile, filePath);
|
|
75
|
-
return true;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export function shannonEntropy(str) {
|
package/src/stack-detector.mjs
CHANGED
|
@@ -119,7 +119,10 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
119
119
|
return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// 5. Python / Elixir / Ruby / Java
|
|
122
|
+
// 5. Python / Django / Elixir / Ruby / Java
|
|
123
|
+
if (existsSync(join(projectRoot, "manage.py"))) {
|
|
124
|
+
return { ...container, stack: "django", testCmd: "python manage.py test --keepdb", buildCmd: "python manage.py check", triggerFile: "manage.py" };
|
|
125
|
+
}
|
|
123
126
|
if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
|
|
124
127
|
const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
|
|
125
128
|
return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile };
|
|
@@ -138,14 +141,21 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
138
141
|
return { ...container, stack: "gradle", testCmd: "./gradlew test", buildCmd: "./gradlew assemble", triggerFile };
|
|
139
142
|
}
|
|
140
143
|
|
|
141
|
-
// 6. JS / TS Runtimes
|
|
144
|
+
// 6. JS / TS Runtimes & ORMs (Prisma, Drizzle)
|
|
145
|
+
let setupCmd = "";
|
|
146
|
+
if (existsSync(join(projectRoot, "prisma", "schema.prisma"))) {
|
|
147
|
+
setupCmd = "npx prisma db push --schema=prisma/schema.prisma";
|
|
148
|
+
} else if (existsSync(join(projectRoot, "drizzle.config.ts")) || existsSync(join(projectRoot, "drizzle.config.js"))) {
|
|
149
|
+
setupCmd = "npx drizzle-kit push";
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb"))) {
|
|
143
153
|
const triggerFile = existsSync(join(projectRoot, "bunfig.toml")) ? "bunfig.toml" : "bun.lockb";
|
|
144
|
-
return { ...container, stack: "bun", testCmd: "bun test", buildCmd: "bun run build", triggerFile };
|
|
154
|
+
return { ...container, stack: "bun", setupCmd, testCmd: "bun test", buildCmd: "bun run build", triggerFile };
|
|
145
155
|
}
|
|
146
156
|
if (existsSync(join(projectRoot, "deno.json")) || existsSync(join(projectRoot, "deno.jsonc"))) {
|
|
147
157
|
const triggerFile = existsSync(join(projectRoot, "deno.json")) ? "deno.json" : "deno.jsonc";
|
|
148
|
-
return { ...container, stack: "deno", testCmd: "deno test", buildCmd: "deno task build", triggerFile };
|
|
158
|
+
return { ...container, stack: "deno", setupCmd, testCmd: "deno test", buildCmd: "deno task build", triggerFile };
|
|
149
159
|
}
|
|
150
160
|
if (existsSync(join(projectRoot, "package.json"))) {
|
|
151
161
|
let testScript = "npm test";
|
|
@@ -161,7 +171,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
161
171
|
testScript = "npm test";
|
|
162
172
|
buildScript = "npm run build";
|
|
163
173
|
}
|
|
164
|
-
return { ...container, stack: "node", testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
|
|
174
|
+
return { ...container, stack: "node", setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
// 7. .NET / C# / F# / PHP / Python root file extension fallback
|