jules-orchestrator-kit 0.32.4 → 0.32.5

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.
@@ -0,0 +1,435 @@
1
+ import {
2
+ readFileSync,
3
+ mkdirSync,
4
+ existsSync,
5
+ readdirSync,
6
+ statSync,
7
+ openSync,
8
+ writeSync,
9
+ fsyncSync,
10
+ closeSync,
11
+ renameSync,
12
+ unlinkSync,
13
+ } from "node:fs";
14
+ import { join, resolve, relative, isAbsolute, sep } from "node:path";
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { execSync } from "node:child_process";
17
+
18
+ /**
19
+ * Normalizes file path to POSIX slashes.
20
+ * @param {string} p
21
+ * @returns {string}
22
+ */
23
+ export function normalizePath(p = "") {
24
+ return p.split(sep).join("/").replace(/\\/g, "/");
25
+ }
26
+
27
+ /**
28
+ * Computes SHA-256 hex digest of a string or buffer.
29
+ * @param {string | Buffer} content
30
+ * @returns {string}
31
+ */
32
+ export function sha256(content) {
33
+ return createHash("sha256").update(content).digest("hex");
34
+ }
35
+
36
+ /**
37
+ * Computes SHA-256 of a single file on disk.
38
+ * @param {string} filePath
39
+ * @returns {string | null}
40
+ */
41
+ export function computeFileHash(filePath) {
42
+ try {
43
+ if (!existsSync(filePath)) return null;
44
+ const content = readFileSync(filePath);
45
+ return "sha256:" + sha256(content);
46
+ } catch (_) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Recursively scans a directory and finds all matching files.
53
+ * @param {string} dir
54
+ * @param {string} [baseDir]
55
+ * @returns {string[]}
56
+ */
57
+ export function findFilesRecursively(dir, baseDir = dir) {
58
+ if (!existsSync(dir)) return [];
59
+ const results = [];
60
+ try {
61
+ const entries = readdirSync(dir, { withFileTypes: true });
62
+ for (const entry of entries) {
63
+ const fullPath = join(dir, entry.name);
64
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target" || entry.name === "vendor") {
65
+ continue;
66
+ }
67
+ if (entry.isDirectory()) {
68
+ results.push(...findFilesRecursively(fullPath, baseDir));
69
+ } else if (entry.isFile()) {
70
+ results.push(normalizePath(relative(baseDir, fullPath)));
71
+ }
72
+ }
73
+ } catch (_) {}
74
+ return results.sort();
75
+ }
76
+
77
+ /**
78
+ * Deterministically computes SHA-256 tree hash over a directory or set of files.
79
+ * Ignores mtime and permissions to ensure byte-level determinism.
80
+ * @param {string} root
81
+ * @param {object} [options]
82
+ * @param {string[]} [options.paths] - Specific paths to hash (relative to root)
83
+ * @param {string[]} [options.directories] - Specific directory names to scan (e.g. ['test', 'tests'])
84
+ * @returns {{ treeHash: string, fileCount: number, fileHashes: Record<string, string> }}
85
+ */
86
+ export function computeDirectoryHash(root, options = {}) {
87
+ let fileList = [];
88
+
89
+ if (Array.isArray(options.paths) && options.paths.length > 0) {
90
+ fileList = options.paths
91
+ .map((p) => normalizePath(p))
92
+ .filter((p) => existsSync(join(root, p)) && statSync(join(root, p)).isFile())
93
+ .sort();
94
+ } else {
95
+ const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "src"];
96
+ for (const dirName of targetDirs) {
97
+ const dirPath = join(root, dirName);
98
+ if (existsSync(dirPath)) {
99
+ const found = findFilesRecursively(dirPath, root);
100
+ fileList.push(...found);
101
+ }
102
+ }
103
+ fileList = Array.from(new Set(fileList)).sort();
104
+ }
105
+
106
+ // Filter test files if specifically looking for test suites
107
+ if (options.testOnly) {
108
+ fileList = fileList.filter((f) => {
109
+ const lower = f.toLowerCase();
110
+ return (
111
+ lower.startsWith("test/") ||
112
+ lower.startsWith("tests/") ||
113
+ lower.startsWith("__tests__/") ||
114
+ lower.startsWith("spec/") ||
115
+ lower.includes(".test.") ||
116
+ lower.includes(".spec.") ||
117
+ lower.includes("_test.") ||
118
+ lower.endsWith("test.sol") ||
119
+ lower.endsWith(".t.sol")
120
+ );
121
+ });
122
+ }
123
+
124
+ const fileHashes = {};
125
+ const hashLines = [];
126
+
127
+ for (const relPath of fileList) {
128
+ const fullPath = join(root, relPath);
129
+ const hash = computeFileHash(fullPath);
130
+ if (hash) {
131
+ fileHashes[relPath] = hash;
132
+ hashLines.push(`${relPath}:${hash}`);
133
+ }
134
+ }
135
+
136
+ const treeHash = "sha256:" + sha256(hashLines.join("\n"));
137
+ return {
138
+ treeHash,
139
+ fileCount: hashLines.length,
140
+ fileHashes,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Computes canonical hash for an evidence manifest (excluding the evidenceHash field).
146
+ * @param {object} manifest
147
+ * @returns {string}
148
+ */
149
+ export function computeEvidenceHash(manifest) {
150
+ const canonicalPayload = JSON.stringify({
151
+ schema: manifest.schema,
152
+ manifestId: manifest.manifestId,
153
+ generatedAt: manifest.generatedAt,
154
+ intent: manifest.intent,
155
+ provenance: manifest.provenance,
156
+ testIntegrity: manifest.testIntegrity,
157
+ executionRecords: manifest.executionRecords,
158
+ securityChecks: manifest.securityChecks,
159
+ });
160
+ return "sha256:" + sha256(canonicalPayload);
161
+ }
162
+
163
+ /**
164
+ * Safely writes a file atomically.
165
+ * @param {string} filePath
166
+ * @param {string} content
167
+ */
168
+ function writeFileAtomically(filePath, content) {
169
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
170
+ let fd;
171
+ try {
172
+ fd = openSync(tmpPath, "wx", 0o600);
173
+ writeSync(fd, content, "utf-8");
174
+ fsyncSync(fd);
175
+ closeSync(fd);
176
+ fd = undefined;
177
+ renameSync(tmpPath, filePath);
178
+ } catch (err) {
179
+ if (fd !== undefined) {
180
+ try { closeSync(fd); } catch (_) {}
181
+ }
182
+ try { unlinkSync(tmpPath); } catch (_) {}
183
+ throw err;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Queries current git provenance data safely.
189
+ * @param {string} root
190
+ * @returns {object}
191
+ */
192
+ export function getGitProvenance(root = process.cwd()) {
193
+ try {
194
+ const commitSha = execSync("git rev-parse HEAD", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
195
+ let branch = "";
196
+ try {
197
+ branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
198
+ } catch (_) {}
199
+
200
+ let statusOutput = "";
201
+ try {
202
+ statusOutput = execSync("git status --porcelain", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
203
+ } catch (_) {}
204
+
205
+ return {
206
+ commitSha,
207
+ branch: branch || "HEAD",
208
+ dirty: statusOutput.length > 0,
209
+ };
210
+ } catch (_) {
211
+ return {
212
+ commitSha: "unknown",
213
+ branch: "unknown",
214
+ dirty: false,
215
+ };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Generates an Evidence Manifest capturing deterministic build, test, and security evidence.
221
+ * @param {string} root
222
+ * @param {object} [options]
223
+ * @returns {object}
224
+ */
225
+ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
226
+ const manifestId = options.manifestId || `EVD-${Date.now()}-${randomUUID().slice(0, 8)}`;
227
+ const generatedAt = new Date().toISOString();
228
+ const gitInfo = getGitProvenance(root);
229
+
230
+ // Compute test tree integrity
231
+ const preTestHash = options.preTestHash || null;
232
+ const currentTestState = computeDirectoryHash(root, { testOnly: true });
233
+ const postTestHash = currentTestState.treeHash;
234
+
235
+ let tamperDetected = false;
236
+ if (preTestHash && preTestHash !== postTestHash) {
237
+ tamperDetected = true;
238
+ }
239
+
240
+ // Intent metadata
241
+ const taskPrompt = options.prompt || options.task?.prompt || "";
242
+ const promptHash = taskPrompt ? "sha256:" + sha256(taskPrompt) : "sha256:empty";
243
+ const promptExcerpt = taskPrompt ? taskPrompt.slice(0, 140).replace(/[\r\n]+/g, " ") : "";
244
+
245
+ // Base manifest
246
+ const baseManifest = {
247
+ schema: "agentctl/evidence-manifest-v1",
248
+ manifestId,
249
+ generatedAt,
250
+ intent: {
251
+ taskId: options.taskId || options.task?.id || "local-task",
252
+ title: options.title || options.task?.title || "Verification Run",
253
+ promptHash,
254
+ promptExcerpt,
255
+ },
256
+ provenance: {
257
+ commitSha: gitInfo.commitSha,
258
+ branch: gitInfo.branch,
259
+ dirty: gitInfo.dirty,
260
+ repository: options.repository || "local",
261
+ },
262
+ testIntegrity: {
263
+ preTestHash: preTestHash || postTestHash,
264
+ postTestHash,
265
+ tamperDetected,
266
+ testFileCount: currentTestState.fileCount,
267
+ fileHashes: currentTestState.fileHashes,
268
+ },
269
+ executionRecords: options.executionRecords || [],
270
+ securityChecks: {
271
+ secretScanOk: options.secretScanOk ?? true,
272
+ diffKb: options.diffKb || 0,
273
+ maxDiffKb: options.maxDiffKb || 75,
274
+ protectedScopeOk: options.protectedScopeOk ?? true,
275
+ },
276
+ };
277
+
278
+ const evidenceHash = computeEvidenceHash(baseManifest);
279
+ return {
280
+ ...baseManifest,
281
+ evidenceHash,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Persists an evidence manifest to disk.
287
+ * @param {string} root
288
+ * @param {object} manifest
289
+ * @param {string} [customPath]
290
+ * @returns {string} Target path
291
+ */
292
+ export function writeEvidenceManifest(root, manifest, customPath = null) {
293
+ const evidenceDir = join(root, ".agent", "evidence");
294
+ if (!existsSync(evidenceDir)) {
295
+ mkdirSync(evidenceDir, { recursive: true });
296
+ }
297
+
298
+ const targetPath = customPath
299
+ ? isAbsolute(customPath) ? customPath : join(root, customPath)
300
+ : join(evidenceDir, `${manifest.manifestId}.json`);
301
+
302
+ writeFileAtomically(targetPath, JSON.stringify(manifest, null, 2));
303
+
304
+ // Also maintain symlink/latest pointer
305
+ const latestPath = join(evidenceDir, "manifest.v1.json");
306
+ try {
307
+ writeFileAtomically(latestPath, JSON.stringify(manifest, null, 2));
308
+ } catch (_) {}
309
+
310
+ return targetPath;
311
+ }
312
+
313
+ /**
314
+ * Loads and validates an evidence manifest from disk.
315
+ * @param {string} root
316
+ * @param {string} [manifestIdOrPath]
317
+ * @returns {object}
318
+ */
319
+ export function loadEvidenceManifest(root, manifestIdOrPath = "manifest.v1.json") {
320
+ const evidenceDir = resolve(root, ".agent", "evidence");
321
+ let targetPath;
322
+
323
+ if (manifestIdOrPath.endsWith(".json")) {
324
+ targetPath = isAbsolute(manifestIdOrPath) ? resolve(manifestIdOrPath) : resolve(evidenceDir, manifestIdOrPath);
325
+ if (!existsSync(targetPath)) {
326
+ targetPath = resolve(root, manifestIdOrPath);
327
+ }
328
+ } else {
329
+ targetPath = join(evidenceDir, `${manifestIdOrPath}.json`);
330
+ }
331
+
332
+ if (!existsSync(targetPath)) {
333
+ throw new Error(`Evidence manifest not found: ${targetPath}`);
334
+ }
335
+
336
+ const raw = readFileSync(targetPath, "utf-8");
337
+ const manifest = JSON.parse(raw);
338
+
339
+ if (manifest.schema !== "agentctl/evidence-manifest-v1") {
340
+ throw new Error(`Invalid evidence manifest schema: ${manifest.schema}`);
341
+ }
342
+
343
+ const expectedHash = computeEvidenceHash(manifest);
344
+ if (manifest.evidenceHash !== expectedHash) {
345
+ throw new Error(`Tampered evidence manifest hash mismatch in ${targetPath}`);
346
+ }
347
+
348
+ return manifest;
349
+ }
350
+
351
+ /**
352
+ * Validates current workspace state against an evidence manifest.
353
+ * Asserts zero test tampering and matching test suite tree hash.
354
+ * @param {string} root
355
+ * @param {object | string} manifestOrPath
356
+ * @returns {{ ok: boolean, reason?: string, details?: object }}
357
+ */
358
+ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "manifest.v1.json") {
359
+ let manifest;
360
+ try {
361
+ manifest = typeof manifestOrPath === "object" && manifestOrPath !== null
362
+ ? manifestOrPath
363
+ : loadEvidenceManifest(root, manifestOrPath);
364
+ } catch (err) {
365
+ return { ok: false, reason: `Failed to load evidence manifest: ${err.message}` };
366
+ }
367
+
368
+ // 1. Verify schema & cryptographic hash
369
+ if (manifest.schema !== "agentctl/evidence-manifest-v1") {
370
+ return { ok: false, reason: `Invalid schema version: ${manifest.schema}` };
371
+ }
372
+ if (manifest.evidenceHash !== computeEvidenceHash(manifest)) {
373
+ return { ok: false, reason: "Evidence manifest hash signature mismatch (tampered file)" };
374
+ }
375
+
376
+ // 2. Verify test integrity / No test weakening
377
+ const currentTestState = computeDirectoryHash(root, { testOnly: true });
378
+ if (manifest.testIntegrity?.tamperDetected) {
379
+ return {
380
+ ok: false,
381
+ reason: "Test suite tampering detected in evidence manifest",
382
+ details: { preTestHash: manifest.testIntegrity.preTestHash, postTestHash: manifest.testIntegrity.postTestHash },
383
+ };
384
+ }
385
+
386
+ if (manifest.testIntegrity?.postTestHash && currentTestState.treeHash !== manifest.testIntegrity.postTestHash) {
387
+ return {
388
+ ok: false,
389
+ reason: `Current test suite tree hash (${currentTestState.treeHash}) does not match manifest evidence (${manifest.testIntegrity.postTestHash})`,
390
+ details: { currentHash: currentTestState.treeHash, manifestHash: manifest.testIntegrity.postTestHash },
391
+ };
392
+ }
393
+
394
+ // 3. Verify security checks
395
+ if (manifest.securityChecks?.secretScanOk === false) {
396
+ return { ok: false, reason: "Evidence manifest records secret scanning failure" };
397
+ }
398
+ if (manifest.securityChecks?.protectedScopeOk === false) {
399
+ return { ok: false, reason: "Evidence manifest records protected scope violation" };
400
+ }
401
+
402
+ return { ok: true, manifestId: manifest.manifestId, evidenceHash: manifest.evidenceHash };
403
+ }
404
+
405
+ /**
406
+ * Renders PR-ready Markdown evidence section.
407
+ * @param {object} manifest
408
+ * @returns {string}
409
+ */
410
+ export function generateEvidenceMarkdown(manifest) {
411
+ const lines = [
412
+ "### 🛡️ Autonomous Verification & Evidence Proof",
413
+ "",
414
+ `> **Evidence Manifest**: \`${manifest.manifestId}\` | **Signature**: \`${manifest.evidenceHash?.slice(0, 16)}...\``,
415
+ "",
416
+ "| Gate / Policy | Status | Evidence Record |",
417
+ "| :--- | :---: | :--- |",
418
+ `| **Test Suite Integrity** | ${manifest.testIntegrity?.tamperDetected ? "❌ Tampered" : "✅ Verified"} | \`${manifest.testIntegrity?.testFileCount || 0} test files\` (Tree: \`${manifest.testIntegrity?.postTestHash?.slice(0, 16)}...\`) |`,
419
+ `| **Secret Leak Scanner** | ${manifest.securityChecks?.secretScanOk ? "✅ Clean" : "❌ Detected"} | High-confidence entropy scan passed |`,
420
+ `| **Payload Governor** | ${manifest.securityChecks?.diffKb <= manifest.securityChecks?.maxDiffKb ? "✅ Bounded" : "❌ Exceeded"} | \`${manifest.securityChecks?.diffKb || 0} KB\` / \`${manifest.securityChecks?.maxDiffKb || 75} KB\` limit |`,
421
+ ];
422
+
423
+ if (Array.isArray(manifest.executionRecords) && manifest.executionRecords.length > 0) {
424
+ lines.push("", "#### Executed Verification Stages");
425
+ lines.push("| Stage | Command | Policy | Exit Code | Duration |");
426
+ lines.push("| :--- | :--- | :---: | :---: | :---: |");
427
+ for (const rec of manifest.executionRecords) {
428
+ const statusIcon = rec.exitCode === 0 ? "✅" : "❌";
429
+ const policyStr = rec.networkAccess === "forbidden" ? "🔒 Offline" : rec.networkAccess === "read-only" ? "📖 Read-Only" : "🌐 Online";
430
+ lines.push(`| \`${rec.id || rec.kind}\` | \`${rec.cmd}\` | ${policyStr} | ${statusIcon} \`${rec.exitCode}\` | \`${rec.durationMs || 0}ms\` |`);
431
+ }
432
+ }
433
+
434
+ return lines.join("\n");
435
+ }
package/src/mcp.mjs CHANGED
@@ -98,6 +98,8 @@ export const MCP_TOOLS = [
98
98
  properties: {
99
99
  title: { type: "string", description: "Short descriptive title for the task" },
100
100
  prompt: { type: "string", description: "Detailed task instructions and prompt" },
101
+ role: { type: "string", description: "Specialist agent role (overseer | bolt | sentinel | janitor)" },
102
+ tier: { type: "string", description: "Force the Cost Router tier when router.enabled (fast | complex); omit to let the heuristic classifier decide" },
101
103
  },
102
104
  required: ["prompt"],
103
105
  },
@@ -178,6 +180,20 @@ export const MCP_TOOLS = [
178
180
  },
179
181
  },
180
182
  },
183
+ {
184
+ name: "record_system_learning",
185
+ description: "Record a persistent system learning or platform quirk into .agent/SYSTEM_LEARNINGS.md (SPORE Memory).",
186
+ inputSchema: {
187
+ type: "object",
188
+ properties: {
189
+ trigger: { type: "string", description: "Trigger condition or symptom (e.g. 'Cloudflare Workers SSR')" },
190
+ solution: { type: "string", description: "Mandatory rule or resolution (e.g. 'Do not use node:fs in edge runtime')" },
191
+ agent: { type: "string", description: "Agent name (defaults to 'agent')" },
192
+ category: { type: "string", description: "Category (e.g. 'EDGE', 'DATABASE', 'GENERAL')" },
193
+ },
194
+ required: ["trigger", "solution"],
195
+ },
196
+ },
181
197
  ];
182
198
 
183
199
  export async function handleMcpRequest(request, opts = {}) {
@@ -220,7 +236,12 @@ export async function handleMcpRequest(request, opts = {}) {
220
236
  try {
221
237
  if (toolName === "dispatch_jules_task") {
222
238
  const session = await dispatch(
223
- { title: args.title || "MCP Task Dispatch", prompt: args.prompt },
239
+ {
240
+ title: args.title || "MCP Task Dispatch",
241
+ prompt: args.prompt,
242
+ role: args.role,
243
+ tier: args.tier === "fast" || args.tier === "complex" ? args.tier : undefined,
244
+ },
224
245
  { root, config }
225
246
  );
226
247
  return {
@@ -232,6 +253,23 @@ export async function handleMcpRequest(request, opts = {}) {
232
253
  };
233
254
  }
234
255
 
256
+ if (toolName === "record_system_learning") {
257
+ const { recordLearning } = await import("./memory.mjs");
258
+ const result = recordLearning(root, {
259
+ trigger: args.trigger,
260
+ solution: args.solution,
261
+ agent: args.agent || "mcp-agent",
262
+ category: args.category || "GENERAL",
263
+ });
264
+ return {
265
+ jsonrpc: "2.0",
266
+ id,
267
+ result: {
268
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }, null, 2) }],
269
+ },
270
+ };
271
+ }
272
+
235
273
  if (toolName === "audit_jules_gate") {
236
274
  const res = await gate({
237
275
  root,
@@ -0,0 +1,74 @@
1
+ import {
2
+ generateEvidenceManifest,
3
+ writeEvidenceManifest,
4
+ loadEvidenceManifest,
5
+ verifyEvidenceManifest,
6
+ generateEvidenceMarkdown,
7
+ } from "../evidence.mjs";
8
+ import { resolveRoot } from "../config.mjs";
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ /**
13
+ * Generates an evidence manifest and optional markdown summary.
14
+ * @param {string} root
15
+ * @param {object} [options]
16
+ * @returns {object}
17
+ */
18
+ export function planEvidenceGenerate(root = resolveRoot(), options = {}) {
19
+ const manifest = generateEvidenceManifest(root, options);
20
+ const manifestPath = writeEvidenceManifest(root, manifest, options.output);
21
+ const markdown = generateEvidenceMarkdown(manifest);
22
+
23
+ if (options.markdownOutput) {
24
+ const mdPath = options.markdownOutput.endsWith(".md")
25
+ ? options.markdownOutput
26
+ : join(root, ".agent", "evidence", "EVIDENCE.md");
27
+ try {
28
+ writeFileSync(mdPath, markdown, "utf-8");
29
+ } catch (_) {}
30
+ }
31
+
32
+ return {
33
+ ok: true,
34
+ manifest,
35
+ manifestPath,
36
+ markdown,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Verifies an evidence manifest against the current working directory.
42
+ * @param {string} root
43
+ * @param {object} [options]
44
+ * @returns {object}
45
+ */
46
+ export function planEvidenceVerify(root = resolveRoot(), options = {}) {
47
+ const manifestTarget = options.manifest || "manifest.v1.json";
48
+ const result = verifyEvidenceManifest(root, manifestTarget);
49
+ return result;
50
+ }
51
+
52
+ /**
53
+ * Loads and displays the latest evidence manifest.
54
+ * @param {string} root
55
+ * @param {object} [options]
56
+ * @returns {object}
57
+ */
58
+ export function planEvidenceShow(root = resolveRoot(), options = {}) {
59
+ const manifestTarget = options.manifest || "manifest.v1.json";
60
+ try {
61
+ const manifest = loadEvidenceManifest(root, manifestTarget);
62
+ const markdown = generateEvidenceMarkdown(manifest);
63
+ return {
64
+ ok: true,
65
+ manifest,
66
+ markdown,
67
+ };
68
+ } catch (err) {
69
+ return {
70
+ ok: false,
71
+ reason: err.message,
72
+ };
73
+ }
74
+ }
package/src/provider.mjs CHANGED
@@ -37,6 +37,19 @@ export const CODEX_PRESET = {
37
37
  promptViaStdin: false,
38
38
  };
39
39
 
40
+ // Google Gemini CLI (`npm i -g @google/gemini-cli`), headless mode. Reads the
41
+ // prompt from stdin (non-TTY input triggers headless mode without needing a
42
+ // dangling `-p` flag), auto-approves file/shell actions, and defaults to the
43
+ // Gemini 3.6 Flash model — a cheap, fast tier suited for trivial/mechanical
44
+ // tasks routed by src/router.mjs. Auth via GEMINI_API_KEY (env, not CLI arg).
45
+ export const GEMINI_PRESET = {
46
+ name: "gemini-flash",
47
+ type: "exec",
48
+ command: "gemini",
49
+ args: ["--model", "gemini-3.6-flash", "--approval-mode=yolo", "--output-format", "json"],
50
+ promptViaStdin: true,
51
+ };
52
+
40
53
  export class MissingApiKeyError extends Error {
41
54
  constructor(message = "JULES_API_KEY environment variable is required for Google Jules API dispatch.") {
42
55
  super(message);
@@ -105,18 +118,33 @@ function interpolateDeep(node, data) {
105
118
  /**
106
119
  * Creates an HTTP or Exec provider adapter based on spec/config.
107
120
  */
121
+ const NAMED_PRESETS = {
122
+ jules: JULES_PRESET,
123
+ "claude-code": CLAUDE_PRESET,
124
+ codex: CODEX_PRESET,
125
+ "gemini-flash": GEMINI_PRESET,
126
+ "gemini-cli": GEMINI_PRESET,
127
+ };
128
+
108
129
  export function createProvider(spec = "jules", config = {}) {
109
130
  if (spec && typeof spec === "object" && typeof spec.dispatch === "function") {
110
131
  return spec;
111
132
  }
112
- const providerSpec = typeof spec === "string"
113
- ? (spec === "claude-code" ? CLAUDE_PRESET : spec === "codex" ? CODEX_PRESET : JULES_PRESET)
114
- : spec || JULES_PRESET;
133
+ const providerSpec = typeof spec === "string" ? (NAMED_PRESETS[spec] || JULES_PRESET) : spec || JULES_PRESET;
115
134
 
116
135
  if (!providerSpec || typeof providerSpec !== "object") {
117
136
  throw new TypeError("Provider specification must be a string or object");
118
137
  }
119
138
 
139
+ if (providerSpec.type === "http") {
140
+ if (typeof providerSpec.url === "string" && providerSpec.url.includes("{token}")) {
141
+ throw new Error("CRITICAL: Insecure token interpolation in provider URL template. API tokens must be passed via headers, not URL paths or query params.");
142
+ }
143
+ if (typeof providerSpec.sendMessageUrl === "string" && providerSpec.sendMessageUrl.includes("{token}")) {
144
+ throw new Error("CRITICAL: Insecure token interpolation in provider sendMessageUrl template. API tokens must be passed via headers, not URL paths or query params.");
145
+ }
146
+ }
147
+
120
148
  return {
121
149
  name: providerSpec.name || "custom-provider",
122
150
 
@@ -165,11 +193,12 @@ export function createProvider(spec = "jules", config = {}) {
165
193
  }
166
194
 
167
195
  if (providerSpec.type === "http") {
168
- const urlData = { ...data, token: rawToken };
196
+ const urlData = { ...data };
169
197
  const url = interpolateString(providerSpec.url, urlData);
198
+ const headerData = { ...urlData, token: rawToken };
170
199
  const headers = {};
171
200
  for (const [k, v] of Object.entries(providerSpec.headers || {})) {
172
- const val = interpolateString(v, urlData);
201
+ const val = interpolateString(v, headerData);
173
202
  if (val.includes("\r") || val.includes("\n")) {
174
203
  throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
175
204
  }
@@ -335,12 +364,13 @@ export function createProvider(spec = "jules", config = {}) {
335
364
 
336
365
  if (providerSpec.type === "http") {
337
366
  const sendMessageUrlTemplate = providerSpec.sendMessageUrl || `${providerSpec.url}/${sessionId}:sendMessage`;
338
- const urlData = { sessionId, token: rawToken, ...ctx };
367
+ const urlData = { sessionId, ...ctx };
339
368
  const url = interpolateString(sendMessageUrlTemplate, urlData);
340
369
 
370
+ const headerData = { ...urlData, token: rawToken };
341
371
  const headers = {};
342
372
  for (const [k, v] of Object.entries(providerSpec.headers || {})) {
343
- const val = interpolateString(v, urlData);
373
+ const val = interpolateString(v, headerData);
344
374
  if (val.includes("\r") || val.includes("\n")) {
345
375
  throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
346
376
  }