@gajae-code/coding-agent 0.11.5 → 0.11.7
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/CHANGELOG.md +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +67 -7
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +35 -4
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +71 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +87 -28
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +20 -7
- package/src/sdk/bus/index.ts +8 -7
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
- package/src/sdk/bus/telegram-daemon-control.ts +26 -8
- package/src/sdk/bus/telegram-daemon.ts +1736 -338
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +1212 -768
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +385 -85
- package/src/session/yield-queue.ts +20 -0
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +37 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
package/src/tools/image-gen.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import * as net from "node:net";
|
|
1
4
|
import * as os from "node:os";
|
|
2
5
|
import * as path from "node:path";
|
|
3
6
|
import { getAntigravityUserAgent, getEnvApiKey, type Model } from "@gajae-code/ai";
|
|
@@ -23,13 +26,16 @@ import packageJson from "../../package.json" with { type: "json" };
|
|
|
23
26
|
import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
|
|
24
27
|
import type { CustomTool } from "../extensibility/custom-tools/types";
|
|
25
28
|
import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
|
|
29
|
+
import { isPrivateOrSpecialAddress, validatePublicHttpUrl } from "../web/insane/url-guard";
|
|
26
30
|
import { resolveReadPath } from "./path-utils";
|
|
27
31
|
|
|
28
32
|
const DEFAULT_MODEL = "gemini-3-pro-image-preview";
|
|
29
|
-
const DEFAULT_OPENROUTER_MODEL = "google/gemini-3-pro-image-preview";
|
|
30
|
-
const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3-pro-image";
|
|
31
33
|
const IMAGE_TIMEOUT = 3 * 60 * 1000; // 3 minutes
|
|
32
34
|
const MAX_IMAGE_SIZE = 35 * 1024 * 1024;
|
|
35
|
+
const MAX_IMAGE_REDIRECTS = 5;
|
|
36
|
+
const MAX_IMAGE_HEADER_SIZE = 16 * 1024;
|
|
37
|
+
const MAX_IMAGE_ERROR_PREVIEW_SIZE = 8 * 1024;
|
|
38
|
+
const IMAGE_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
33
39
|
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
34
40
|
const OPENAI_IMAGE_OUTPUT_FORMAT = "webp";
|
|
35
41
|
const OPENAI_IMAGE_MIME_TYPE = "image/webp";
|
|
@@ -327,6 +333,160 @@ function toDataUrl(image: InlineImageData): string {
|
|
|
327
333
|
return `data:${image.mimeType};base64,${image.data}`;
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
function withAbortSignal<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
337
|
+
if (!signal) return operation;
|
|
338
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
339
|
+
const deferred = Promise.withResolvers<T>();
|
|
340
|
+
const abort = () => deferred.reject(signal.reason);
|
|
341
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
342
|
+
operation.then(deferred.resolve, deferred.reject).finally(() => signal.removeEventListener("abort", abort));
|
|
343
|
+
return deferred.promise;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizePeerAddress(address: string): string {
|
|
347
|
+
const normalized = address.toLowerCase();
|
|
348
|
+
return normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizeUrlHostname(hostname: string): string {
|
|
352
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function responseHeaderByteLength(response: http.IncomingMessage): number {
|
|
356
|
+
let bytes = Buffer.byteLength(
|
|
357
|
+
`HTTP/${response.httpVersion} ${response.statusCode ?? ""}${response.statusMessage ? ` ${response.statusMessage}` : ""}\r\n`,
|
|
358
|
+
);
|
|
359
|
+
for (let index = 0; index < response.rawHeaders.length; index += 2) {
|
|
360
|
+
bytes += Buffer.byteLength(`${response.rawHeaders[index] ?? ""}: ${response.rawHeaders[index + 1] ?? ""}\r\n`);
|
|
361
|
+
}
|
|
362
|
+
return bytes + Buffer.byteLength("\r\n");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function validateImageUrl(rawUrl: string, signal: AbortSignal | undefined) {
|
|
366
|
+
return withAbortSignal(validatePublicHttpUrl(rawUrl), signal);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function openImageResponse(
|
|
370
|
+
url: URL,
|
|
371
|
+
addresses: string[],
|
|
372
|
+
signal: AbortSignal | undefined,
|
|
373
|
+
): Promise<http.IncomingMessage> {
|
|
374
|
+
const hostname = normalizeUrlHostname(url.hostname);
|
|
375
|
+
const approved = addresses.map(address => ({ address, family: net.isIP(address) }));
|
|
376
|
+
const approvedPeers = new Set(approved.map(record => normalizePeerAddress(record.address)));
|
|
377
|
+
const lookup: net.LookupFunction = (requestedHostname, options, callback) => {
|
|
378
|
+
const requestedFamily = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : (options.family ?? 0);
|
|
379
|
+
const matching = approved.filter(record => requestedFamily === 0 || record.family === requestedFamily);
|
|
380
|
+
if (normalizeUrlHostname(requestedHostname) !== hostname || matching.length === 0) {
|
|
381
|
+
const error = Object.assign(new Error("No approved address for image host"), { code: "ENOTFOUND" });
|
|
382
|
+
callback(error, options.all ? [] : "", 0);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (options.all) callback(null, matching);
|
|
386
|
+
else callback(null, matching[0].address, matching[0].family);
|
|
387
|
+
};
|
|
388
|
+
const deferred = Promise.withResolvers<http.IncomingMessage>();
|
|
389
|
+
const options: https.RequestOptions = {
|
|
390
|
+
protocol: url.protocol,
|
|
391
|
+
hostname,
|
|
392
|
+
port: url.port || undefined,
|
|
393
|
+
path: `${url.pathname}${url.search}`,
|
|
394
|
+
method: "GET",
|
|
395
|
+
headers: { Accept: "image/*", "Accept-Encoding": "identity", Connection: "close", Host: url.host },
|
|
396
|
+
agent: false,
|
|
397
|
+
insecureHTTPParser: false,
|
|
398
|
+
lookup,
|
|
399
|
+
maxHeaderSize: MAX_IMAGE_HEADER_SIZE,
|
|
400
|
+
signal,
|
|
401
|
+
...(url.protocol === "https:"
|
|
402
|
+
? { rejectUnauthorized: true, servername: net.isIP(hostname) === 0 ? hostname : undefined }
|
|
403
|
+
: {}),
|
|
404
|
+
};
|
|
405
|
+
const requestFn = url.protocol === "https:" ? https.request : http.request;
|
|
406
|
+
const request = requestFn(options, response => {
|
|
407
|
+
if (responseHeaderByteLength(response) > MAX_IMAGE_HEADER_SIZE) {
|
|
408
|
+
response.destroy();
|
|
409
|
+
deferred.reject(new Error("Image response headers exceed the maximum size of 16 KiB"));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const peer = response.socket.remoteAddress;
|
|
413
|
+
if (peer && (isPrivateOrSpecialAddress(peer) || !approvedPeers.has(normalizePeerAddress(peer)))) {
|
|
414
|
+
response.destroy();
|
|
415
|
+
deferred.reject(new Error("Refusing image response from an unapproved connected peer"));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
deferred.resolve(response);
|
|
419
|
+
});
|
|
420
|
+
request.once("error", deferred.reject);
|
|
421
|
+
const abort = () => {
|
|
422
|
+
request.destroy(signal?.reason);
|
|
423
|
+
deferred.reject(signal?.reason);
|
|
424
|
+
};
|
|
425
|
+
if (signal?.aborted) abort();
|
|
426
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
427
|
+
request.once("close", () => signal?.removeEventListener("abort", abort));
|
|
428
|
+
request.end();
|
|
429
|
+
return deferred.promise;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validateImageResponseFraming(response: http.IncomingMessage): void {
|
|
433
|
+
const rawContentLengths: string[] = [];
|
|
434
|
+
let hasTransferEncoding = false;
|
|
435
|
+
for (let index = 0; index < response.rawHeaders.length; index += 2) {
|
|
436
|
+
const name = response.rawHeaders[index]?.toLowerCase();
|
|
437
|
+
if (name === "content-length") rawContentLengths.push(response.rawHeaders[index + 1] ?? "");
|
|
438
|
+
if (name === "transfer-encoding") hasTransferEncoding = true;
|
|
439
|
+
}
|
|
440
|
+
if (rawContentLengths.length > 1 || (rawContentLengths.length > 0 && hasTransferEncoding)) {
|
|
441
|
+
response.destroy();
|
|
442
|
+
throw new Error("Image response has ambiguous framing");
|
|
443
|
+
}
|
|
444
|
+
const contentLength = response.headers["content-length"];
|
|
445
|
+
if (contentLength === undefined) return;
|
|
446
|
+
const declaredBytes = typeof contentLength === "string" && /^\d+$/.test(contentLength) ? Number(contentLength) : NaN;
|
|
447
|
+
if (!Number.isSafeInteger(declaredBytes)) {
|
|
448
|
+
response.destroy();
|
|
449
|
+
throw new Error("Image response has an invalid Content-Length");
|
|
450
|
+
}
|
|
451
|
+
if (declaredBytes > MAX_IMAGE_SIZE) {
|
|
452
|
+
response.destroy();
|
|
453
|
+
throw new Error("Image response exceeds the maximum size of 35 MiB");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function readImageResponse(
|
|
458
|
+
response: http.IncomingMessage,
|
|
459
|
+
maxBytes: number,
|
|
460
|
+
signal: AbortSignal | undefined,
|
|
461
|
+
): Promise<Buffer> {
|
|
462
|
+
const abort = () => response.destroy(signal?.reason);
|
|
463
|
+
if (signal?.aborted) {
|
|
464
|
+
abort();
|
|
465
|
+
throw signal.reason;
|
|
466
|
+
}
|
|
467
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
468
|
+
const chunks: Buffer[] = [];
|
|
469
|
+
let receivedBytes = 0;
|
|
470
|
+
try {
|
|
471
|
+
for await (const chunk of response) {
|
|
472
|
+
const bytes = Buffer.from(chunk);
|
|
473
|
+
receivedBytes += bytes.byteLength;
|
|
474
|
+
if (receivedBytes > maxBytes) {
|
|
475
|
+
response.destroy();
|
|
476
|
+
throw new Error(
|
|
477
|
+
maxBytes === MAX_IMAGE_SIZE
|
|
478
|
+
? "Image response exceeds the maximum size of 35 MiB"
|
|
479
|
+
: "Image download error response exceeded the preview limit",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
chunks.push(bytes);
|
|
483
|
+
}
|
|
484
|
+
return Buffer.concat(chunks, receivedBytes);
|
|
485
|
+
} finally {
|
|
486
|
+
signal?.removeEventListener("abort", abort);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
330
490
|
async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise<InlineImageData> {
|
|
331
491
|
if (imageUrl.startsWith("data:")) {
|
|
332
492
|
const normalized = normalizeDataUrl(imageUrl.trim());
|
|
@@ -339,17 +499,40 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
|
|
|
339
499
|
return { data: normalized.data, mimeType: normalized.mimeType };
|
|
340
500
|
}
|
|
341
501
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
502
|
+
let currentUrl = imageUrl;
|
|
503
|
+
for (let redirectCount = 0; ; redirectCount++) {
|
|
504
|
+
const guard = await validateImageUrl(currentUrl, signal);
|
|
505
|
+
if (!guard.ok) {
|
|
506
|
+
throw new Error(`Refusing image URL: target URL is not public HTTP(S): ${guard.reason}`);
|
|
507
|
+
}
|
|
508
|
+
const response = await openImageResponse(guard.url, guard.addresses, signal);
|
|
509
|
+
const status = response.statusCode ?? 0;
|
|
510
|
+
if (IMAGE_REDIRECT_STATUSES.has(status)) {
|
|
511
|
+
if (redirectCount >= MAX_IMAGE_REDIRECTS) {
|
|
512
|
+
response.destroy();
|
|
513
|
+
throw new Error("Too many redirects downloading image");
|
|
514
|
+
}
|
|
515
|
+
const location = response.headers.location;
|
|
516
|
+
response.destroy();
|
|
517
|
+
if (!location) throw new Error("Image redirect is missing a Location header");
|
|
518
|
+
currentUrl = new URL(location, guard.url).toString();
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
validateImageResponseFraming(response);
|
|
522
|
+
if (status < 200 || status >= 300) {
|
|
523
|
+
const preview = (await readImageResponse(response, MAX_IMAGE_ERROR_PREVIEW_SIZE, signal)).toString("utf8");
|
|
524
|
+
throw new Error(`Image download failed (${status}): ${preview}`);
|
|
525
|
+
}
|
|
526
|
+
const rawContentType = response.headers["content-type"];
|
|
527
|
+
const contentType =
|
|
528
|
+
typeof rawContentType === "string" ? rawContentType.split(";", 1)[0]?.trim().toLowerCase() : undefined;
|
|
529
|
+
if (!contentType?.startsWith("image/")) {
|
|
530
|
+
response.destroy();
|
|
531
|
+
throw new Error("Image response is missing a supported image Content-Type");
|
|
532
|
+
}
|
|
533
|
+
const buffer = await readImageResponse(response, MAX_IMAGE_SIZE, signal);
|
|
534
|
+
return { data: buffer.toBase64(), mimeType: contentType };
|
|
350
535
|
}
|
|
351
|
-
const buffer = await response.bytes();
|
|
352
|
-
return { data: buffer.toBase64(), mimeType: contentType };
|
|
353
536
|
}
|
|
354
537
|
|
|
355
538
|
function collectOpenRouterResponseText(message: OpenRouterMessage | undefined): string | undefined {
|
|
@@ -399,6 +582,49 @@ export function setPreferredImageProvider(provider: ImageProvider | "auto"): voi
|
|
|
399
582
|
preferredImageProvider = provider;
|
|
400
583
|
}
|
|
401
584
|
|
|
585
|
+
/** Provider → default image model mapping for auto-binding */
|
|
586
|
+
export const IMAGE_PROVIDER_DEFAULTS: Record<string, string> = {
|
|
587
|
+
openai: "gpt-image-2",
|
|
588
|
+
"openai-codex": "gpt-image-2",
|
|
589
|
+
antigravity: "gemini-3-pro-image",
|
|
590
|
+
gemini: "gemini-3-pro-image-preview",
|
|
591
|
+
openrouter: "google/gemini-3-pro-image-preview",
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/** Resolved image generation configuration from settings */
|
|
595
|
+
export interface ImageProviderConfig {
|
|
596
|
+
provider: ImageProvider | "auto" | "custom";
|
|
597
|
+
model: string | null;
|
|
598
|
+
customUrl?: string;
|
|
599
|
+
customKey?: string;
|
|
600
|
+
customKeyEnv?: string;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Module-level configured image model state (set from settings at session init) */
|
|
604
|
+
let configuredImageConfig: ImageProviderConfig | null = null;
|
|
605
|
+
|
|
606
|
+
/** Set the configured image provider + model from settings */
|
|
607
|
+
export function setConfiguredImageModel(config: ImageProviderConfig | null): void {
|
|
608
|
+
configuredImageConfig = config;
|
|
609
|
+
// Keep preferredImageProvider in sync for backward compat
|
|
610
|
+
if (config && config.provider !== "auto" && config.provider !== "custom") {
|
|
611
|
+
preferredImageProvider = config.provider;
|
|
612
|
+
} else if (!config || config.provider === "auto") {
|
|
613
|
+
preferredImageProvider = "auto";
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Get the current configured image model (for UI display) */
|
|
618
|
+
export function getConfiguredImageModel(): ImageProviderConfig | null {
|
|
619
|
+
return configuredImageConfig;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Resolve the effective image model for a configured provider */
|
|
623
|
+
export function resolveImageModel(provider: string, modelOverride: string | null): string {
|
|
624
|
+
if (modelOverride) return modelOverride;
|
|
625
|
+
return IMAGE_PROVIDER_DEFAULTS[provider] ?? DEFAULT_MODEL;
|
|
626
|
+
}
|
|
627
|
+
|
|
402
628
|
interface ParsedAntigravityCredentials {
|
|
403
629
|
accessToken: string;
|
|
404
630
|
projectId?: string;
|
|
@@ -420,6 +646,22 @@ function parseAntigravityCredentials(raw: string): ParsedAntigravityCredentials
|
|
|
420
646
|
return rawToken.length > 0 ? { accessToken: rawToken } : null;
|
|
421
647
|
}
|
|
422
648
|
|
|
649
|
+
function createCustomImageModel(baseUrl: string, id: string): Model<"openai-responses"> {
|
|
650
|
+
return {
|
|
651
|
+
id,
|
|
652
|
+
name: id,
|
|
653
|
+
api: "openai-responses",
|
|
654
|
+
provider: "openai",
|
|
655
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
656
|
+
reasoning: false,
|
|
657
|
+
input: ["text"],
|
|
658
|
+
output: ["text", "image"],
|
|
659
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
660
|
+
contextWindow: 128_000,
|
|
661
|
+
maxTokens: 16_384,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
423
665
|
async function findAntigravityCredentials(
|
|
424
666
|
modelRegistry: ModelRegistry,
|
|
425
667
|
sessionId?: string,
|
|
@@ -467,7 +709,44 @@ async function findImageApiKey(
|
|
|
467
709
|
activeModel?: Model,
|
|
468
710
|
sessionId?: string,
|
|
469
711
|
): Promise<ImageApiKey | null> {
|
|
470
|
-
//
|
|
712
|
+
// Config-driven routing: if an explicit provider+model is configured, use it.
|
|
713
|
+
if (configuredImageConfig && configuredImageConfig.provider !== "auto") {
|
|
714
|
+
const config = configuredImageConfig;
|
|
715
|
+
if (config.provider === "custom") {
|
|
716
|
+
const baseUrl = config.customUrl?.trim();
|
|
717
|
+
const apiKey = config.customKey ?? (config.customKeyEnv ? Bun.env[config.customKeyEnv] : undefined);
|
|
718
|
+
if (baseUrl && apiKey) {
|
|
719
|
+
return {
|
|
720
|
+
provider: "openai",
|
|
721
|
+
apiKey,
|
|
722
|
+
model: createCustomImageModel(baseUrl, config.model ?? IMAGE_PROVIDER_DEFAULTS.openai),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
// For configured providers, resolve credentials through the existing paths.
|
|
728
|
+
if (config.provider === "openai" || config.provider === "openai-codex") {
|
|
729
|
+
const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
|
|
730
|
+
if (openAI) return openAI;
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
if (config.provider === "antigravity") {
|
|
734
|
+
if (!modelRegistry) return null;
|
|
735
|
+
return await findAntigravityCredentials(modelRegistry, sessionId);
|
|
736
|
+
}
|
|
737
|
+
if (config.provider === "gemini") {
|
|
738
|
+
const geminiKey = getEnvApiKey("google");
|
|
739
|
+
if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
|
|
740
|
+
const googleKey = $env.GOOGLE_API_KEY;
|
|
741
|
+
return googleKey ? { provider: "gemini", apiKey: googleKey } : null;
|
|
742
|
+
}
|
|
743
|
+
if (config.provider === "openrouter") {
|
|
744
|
+
const openRouterKey = getEnvApiKey("openrouter");
|
|
745
|
+
return openRouterKey ? { provider: "openrouter", apiKey: openRouterKey } : null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// If a specific provider is preferred (legacy path), try it first.
|
|
471
750
|
if (preferredImageProvider === "openai") {
|
|
472
751
|
const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
|
|
473
752
|
if (openAI) return openAI;
|
|
@@ -945,13 +1224,15 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
|
|
945
1224
|
|
|
946
1225
|
const provider = apiKey.provider;
|
|
947
1226
|
const model =
|
|
948
|
-
provider
|
|
949
|
-
?
|
|
950
|
-
: provider === "
|
|
951
|
-
?
|
|
952
|
-
: provider === "
|
|
953
|
-
?
|
|
954
|
-
:
|
|
1227
|
+
configuredImageConfig && configuredImageConfig.provider !== "auto" && configuredImageConfig.model
|
|
1228
|
+
? configuredImageConfig.model
|
|
1229
|
+
: provider === "openai" || provider === "openai-codex"
|
|
1230
|
+
? (apiKey.model?.id ?? resolveImageModel(provider, null))
|
|
1231
|
+
: provider === "antigravity"
|
|
1232
|
+
? resolveImageModel("antigravity", null)
|
|
1233
|
+
: provider === "openrouter"
|
|
1234
|
+
? resolveImageModel("openrouter", null)
|
|
1235
|
+
: resolveImageModel("gemini", null);
|
|
955
1236
|
const resolvedModel = provider === "openrouter" ? resolveOpenRouterModel(model) : model;
|
|
956
1237
|
const cwd = ctx.sessionManager.getCwd();
|
|
957
1238
|
|
package/src/tools/read.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
} from "./path-utils";
|
|
74
74
|
import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
|
75
75
|
import {
|
|
76
|
+
enforceSqliteQueryOnly,
|
|
76
77
|
executeReadQuery,
|
|
77
78
|
getRowByKey,
|
|
78
79
|
getRowByRowId,
|
|
@@ -1272,6 +1273,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1272
1273
|
let db: Database | null = null;
|
|
1273
1274
|
try {
|
|
1274
1275
|
db = new Database(resolvedSqlitePath.absolutePath, { readonly: true, strict: true });
|
|
1276
|
+
enforceSqliteQueryOnly(db);
|
|
1275
1277
|
db.run("PRAGMA busy_timeout = 3000");
|
|
1276
1278
|
throwIfAborted(signal);
|
|
1277
1279
|
|
|
@@ -21,6 +21,13 @@ export type SkillDiscoveryToolInput = z.infer<typeof skillDiscoverySchema>;
|
|
|
21
21
|
export interface SkillDiscoveryToolDetails {
|
|
22
22
|
candidates: RuntimeSkillDiscoveryCandidate[];
|
|
23
23
|
count: number;
|
|
24
|
+
/**
|
|
25
|
+
* Present only when zero candidates were returned AND discovery config gates
|
|
26
|
+
* (`skills.enabled` / `skills.enablePiProject` / `skills.enablePiUser`)
|
|
27
|
+
* prevented some or all of the requested scope from being searched. Without
|
|
28
|
+
* this, a disabled config is indistinguishable from "no skills exist".
|
|
29
|
+
*/
|
|
30
|
+
notice?: string;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema, SkillDiscoveryToolDetails> {
|
|
@@ -57,17 +64,46 @@ export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema
|
|
|
57
64
|
signal?: AbortSignal,
|
|
58
65
|
): Promise<AgentToolResult<SkillDiscoveryToolDetails>> {
|
|
59
66
|
return untilAborted(signal, async () => {
|
|
67
|
+
const source = input.source ?? "all";
|
|
60
68
|
const candidates = await discoverRuntimeSkills({
|
|
61
69
|
cwd: this.#session.cwd,
|
|
62
70
|
query: input.query,
|
|
63
|
-
source
|
|
71
|
+
source,
|
|
64
72
|
limit: input.limit,
|
|
65
73
|
policy: this.#getRuntimeSkillPolicy(),
|
|
66
74
|
});
|
|
75
|
+
const details: SkillDiscoveryToolDetails = { candidates, count: candidates.length };
|
|
76
|
+
if (candidates.length === 0) {
|
|
77
|
+
const notice = this.#disabledPolicyNotice(source);
|
|
78
|
+
if (notice) details.notice = notice;
|
|
79
|
+
}
|
|
67
80
|
return {
|
|
68
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
69
|
-
details
|
|
81
|
+
content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
|
|
82
|
+
details,
|
|
70
83
|
};
|
|
71
84
|
});
|
|
72
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Explain an empty result that is caused by disabled discovery config rather
|
|
89
|
+
* than by an actually empty skill catalog.
|
|
90
|
+
*/
|
|
91
|
+
#disabledPolicyNotice(source: "all" | "project" | "user"): string | undefined {
|
|
92
|
+
const policy = this.#getRuntimeSkillPolicy();
|
|
93
|
+
if (policy.enabled !== true) {
|
|
94
|
+
return "Runtime skill discovery is disabled: `skills.enabled` is false, so no skill directories were searched. Enable it with `gjc config set skills.enabled true` (project and user scopes additionally require `skills.enablePiProject` / `skills.enablePiUser`).";
|
|
95
|
+
}
|
|
96
|
+
const skipped: string[] = [];
|
|
97
|
+
const commands: string[] = [];
|
|
98
|
+
if ((source === "all" || source === "project") && policy.enablePiProject !== true) {
|
|
99
|
+
skipped.push("project (`skills.enablePiProject` is false)");
|
|
100
|
+
commands.push("`gjc config set skills.enablePiProject true`");
|
|
101
|
+
}
|
|
102
|
+
if ((source === "all" || source === "user") && policy.enablePiUser !== true) {
|
|
103
|
+
skipped.push("user (`skills.enablePiUser` is false)");
|
|
104
|
+
commands.push("`gjc config set skills.enablePiUser true`");
|
|
105
|
+
}
|
|
106
|
+
if (skipped.length === 0) return undefined;
|
|
107
|
+
return `Skill discovery skipped disabled scope(s): ${skipped.join(", ")}. Enable them with ${commands.join(" and ")}.`;
|
|
108
|
+
}
|
|
73
109
|
}
|
|
@@ -272,6 +272,7 @@ const COMMENT_OR_TERMINATOR_ERROR =
|
|
|
272
272
|
"SQLite 'where' clause must not contain comments or statement terminators; use '?q=SELECT ...' for raw SQL";
|
|
273
273
|
const FORBIDDEN_KEYWORD_ERROR =
|
|
274
274
|
"SQLite 'where' clause must not contain LIMIT/OFFSET/UNION/INTERSECT/EXCEPT/ATTACH/DETACH/PRAGMA; use '?q=SELECT ...' for raw SQL";
|
|
275
|
+
const RAW_QUERY_ERROR = "SQLite raw queries must contain exactly one explicit SELECT statement";
|
|
275
276
|
|
|
276
277
|
/**
|
|
277
278
|
* Scans a `where=` clause character-by-character, tracking single- and double-quoted
|
|
@@ -363,6 +364,66 @@ function validateWhereClause(where: string | undefined): string | undefined {
|
|
|
363
364
|
return trimmed;
|
|
364
365
|
}
|
|
365
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Keep raw reads deliberately narrower than SQLite's full grammar. Comments are
|
|
369
|
+
* rejected, and a statement terminator is accepted only as the final
|
|
370
|
+
* non-whitespace character. Quoted semicolons remain ordinary query data.
|
|
371
|
+
*/
|
|
372
|
+
export function validateSqliteReadQuery(sql: string): string {
|
|
373
|
+
const trimmed = sql.trim();
|
|
374
|
+
if (!/^SELECT\b/i.test(trimmed)) {
|
|
375
|
+
throw new ToolError(RAW_QUERY_ERROR);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let quoteEnd: "'" | '"' | "`" | "]" | null = null;
|
|
379
|
+
for (let index = 0; index < trimmed.length; index++) {
|
|
380
|
+
const char = trimmed[index];
|
|
381
|
+
const next = trimmed[index + 1];
|
|
382
|
+
if (char === "\0") {
|
|
383
|
+
throw new ToolError(RAW_QUERY_ERROR);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (quoteEnd) {
|
|
387
|
+
if (char === quoteEnd) {
|
|
388
|
+
if (quoteEnd !== "]" && next === quoteEnd) {
|
|
389
|
+
index += 1;
|
|
390
|
+
} else {
|
|
391
|
+
quoteEnd = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
398
|
+
quoteEnd = char;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (char === "[") {
|
|
402
|
+
quoteEnd = "]";
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if ((char === "-" && next === "-") || (char === "/" && next === "*") || (char === "*" && next === "/")) {
|
|
406
|
+
throw new ToolError(RAW_QUERY_ERROR);
|
|
407
|
+
}
|
|
408
|
+
if (char === ";" && trimmed.slice(index + 1).trim().length > 0) {
|
|
409
|
+
throw new ToolError(RAW_QUERY_ERROR);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (quoteEnd) {
|
|
414
|
+
throw new ToolError(RAW_QUERY_ERROR);
|
|
415
|
+
}
|
|
416
|
+
return trimmed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function enforceSqliteQueryOnly(db: Database): void {
|
|
420
|
+
db.run("PRAGMA query_only = ON");
|
|
421
|
+
const enabled = db.prepare<{ query_only: number }, []>("PRAGMA query_only").get()?.query_only;
|
|
422
|
+
if (enabled !== 1) {
|
|
423
|
+
throw new ToolError("SQLite read connection could not enable query-only mode");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
366
427
|
function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
|
|
367
428
|
if (value === null) return null;
|
|
368
429
|
if (
|
|
@@ -444,10 +505,7 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
|
|
|
444
505
|
if (normalizedSubPath || otherKeys.length > 0) {
|
|
445
506
|
throw new ToolError("SQLite raw queries cannot be combined with table selectors or pagination");
|
|
446
507
|
}
|
|
447
|
-
|
|
448
|
-
throw new ToolError("SQLite query parameter 'q' cannot be empty");
|
|
449
|
-
}
|
|
450
|
-
return { kind: "raw", sql: rawQuery };
|
|
508
|
+
return { kind: "raw", sql: validateSqliteReadQuery(rawQuery) };
|
|
451
509
|
}
|
|
452
510
|
|
|
453
511
|
if (!normalizedSubPath) {
|
|
@@ -602,7 +660,7 @@ export function executeReadQuery(
|
|
|
602
660
|
sql: string,
|
|
603
661
|
maxRows: number = MAX_RAW_QUERY_ROWS,
|
|
604
662
|
): { columns: string[]; rows: Record<string, unknown>[]; truncated: boolean } {
|
|
605
|
-
const statement = db.prepare<SqliteRow, []>(sql);
|
|
663
|
+
const statement = db.prepare<SqliteRow, []>(validateSqliteReadQuery(sql));
|
|
606
664
|
if (statement.paramsCount > 0) {
|
|
607
665
|
throw new ToolError("SQLite raw queries do not support bound parameters");
|
|
608
666
|
}
|
|
@@ -143,6 +143,7 @@ function tokenizePastedPathCandidates(
|
|
|
143
143
|
text: string,
|
|
144
144
|
options: DecodePastedPathOptions,
|
|
145
145
|
maxCandidates: number,
|
|
146
|
+
requireCompletePayload = false,
|
|
146
147
|
): TokenizePastedPathsResult {
|
|
147
148
|
if (text.length > MAX_PASTED_IMAGE_PASTE_CHARACTERS) return { kind: "invalid" };
|
|
148
149
|
const platform = options.platform ?? process.platform;
|
|
@@ -154,7 +155,9 @@ function tokenizePastedPathCandidates(
|
|
|
154
155
|
|
|
155
156
|
const startCandidate = (): TokenizePastedPathsResult | undefined => {
|
|
156
157
|
if (candidateStarted) return undefined;
|
|
157
|
-
if (candidates.length >= maxCandidates)
|
|
158
|
+
if (!requireCompletePayload && candidates.length >= maxCandidates) {
|
|
159
|
+
return { kind: "too-many", maxCandidates, candidates: [...candidates] };
|
|
160
|
+
}
|
|
158
161
|
candidateStarted = true;
|
|
159
162
|
return undefined;
|
|
160
163
|
};
|
|
@@ -167,7 +170,9 @@ function tokenizePastedPathCandidates(
|
|
|
167
170
|
const finishCandidate = (): TokenizePastedPathsResult | undefined => {
|
|
168
171
|
if (!candidateStarted) return undefined;
|
|
169
172
|
if (!candidate) return { kind: "invalid" };
|
|
170
|
-
if (candidates.length >= maxCandidates)
|
|
173
|
+
if (!requireCompletePayload && candidates.length >= maxCandidates) {
|
|
174
|
+
return { kind: "too-many", maxCandidates, candidates: [...candidates] };
|
|
175
|
+
}
|
|
171
176
|
const decoded = decodeListToken(candidate, options, platform);
|
|
172
177
|
if (decoded === undefined) return { kind: "invalid" };
|
|
173
178
|
candidates.push(decoded);
|
|
@@ -225,6 +230,7 @@ function tokenizePastedPathCandidates(
|
|
|
225
230
|
if (state !== "normal") return { kind: "invalid" };
|
|
226
231
|
const finalResult = finishCandidate();
|
|
227
232
|
if (finalResult) return finalResult;
|
|
233
|
+
if (candidates.length > maxCandidates) return { kind: "too-many", maxCandidates, candidates };
|
|
228
234
|
return candidates.length === 0 ? { kind: "invalid" } : { kind: "candidates", candidates };
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -257,8 +263,12 @@ function isRecognizedClipboardTempPath(filePath: string, platform: NodeJS.Platfo
|
|
|
257
263
|
/** Resolve one recognized clipboard-temp image path lexically, before any filesystem access. */
|
|
258
264
|
export function resolvePastedImagePath(text: string, options: ResolvePastedImagePathOptions = {}): string | undefined {
|
|
259
265
|
const platform = options.platform ?? process.platform;
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
266
|
+
const terminalNewlinesTrimmed = text.replace(/(?:\r\n|\r|\n)+$/, "");
|
|
267
|
+
if (/\r|\n/.test(terminalNewlinesTrimmed)) return undefined;
|
|
268
|
+
const candidates = decodePastedPathCandidates(terminalNewlinesTrimmed, options, 1);
|
|
269
|
+
if (candidates?.length !== 1) return undefined;
|
|
270
|
+
const candidate = candidates[0];
|
|
271
|
+
if (!IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
|
|
262
272
|
if (platform === "win32" && isRemoteWindowsPath(candidate)) return undefined;
|
|
263
273
|
const resolved = resolveCandidate(candidate, options, platform);
|
|
264
274
|
return isRecognizedClipboardTempPath(resolved, platform) ? resolved : undefined;
|
|
@@ -274,10 +284,9 @@ export function parsePastedImagePaths(
|
|
|
274
284
|
options: ResolvePastedImagePathOptions = {},
|
|
275
285
|
): PastedImagePathParseResult | undefined {
|
|
276
286
|
const platform = options.platform ?? process.platform;
|
|
277
|
-
const tokenized = tokenizePastedPathCandidates(text, options, MAX_PASTED_IMAGE_COUNT);
|
|
287
|
+
const tokenized = tokenizePastedPathCandidates(text, options, MAX_PASTED_IMAGE_COUNT, true);
|
|
278
288
|
if (tokenized.kind === "too-many") {
|
|
279
289
|
if (
|
|
280
|
-
tokenized.candidates.length !== MAX_PASTED_IMAGE_COUNT ||
|
|
281
290
|
tokenized.candidates.some(
|
|
282
291
|
candidate =>
|
|
283
292
|
!IMAGE_FILE_EXTENSION_PATTERN.test(candidate) ||
|