fix(ai): corrected OAuth callback flows and hardened credential storage permissions
- Fixed OAuth callback flows for GitHub Copilot and Google Gemini to handle cancellation and manual input errors. - Hardened database file permissions to 0o600 and directory creation to 0o700 to prevent credential leakage. - Fixed cache invalidation for streaming edits and file existence checks for prompt templates. - Fixed bash output streaming to prevent premature closure and LSP client request handling for aborted signals.
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Fixed
|
||||
|
||||
- Improved error handling for aborted requests in Google Gemini CLI provider
|
||||
- Enhanced OAuth callback flow to handle manual input errors gracefully
|
||||
- Fixed login cancellation handling in GitHub Copilot OAuth flow
|
||||
- Removed fallback manual input from OpenAI Codex OAuth flow
|
||||
|
||||
### Security
|
||||
|
||||
- Hardened database file permissions to prevent credential leakage
|
||||
- Set secure directory permissions (0o700) for credential storage
|
||||
|
||||
## [6.8.0] - 2026-01-20
|
||||
|
||||
|
||||
@@ -753,7 +753,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
|
||||
if (emptyAttempt > 0) {
|
||||
const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
|
||||
await abortableSleep(backoffMs, options?.signal);
|
||||
try {
|
||||
await abortableSleep(backoffMs, options?.signal);
|
||||
} catch {
|
||||
// Normalize AbortError to expected message for consistent error handling
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (!requestUrl) {
|
||||
throw new Error("Missing request URL");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { OAuthCredentials } from "./utils/oauth/types";
|
||||
@@ -88,13 +88,19 @@ export class CliAuthStorage {
|
||||
private deleteByProviderStmt: ReturnType<Database["prepare"]>;
|
||||
|
||||
constructor(dbPath: string = getAgentDbPath()) {
|
||||
// Ensure directory exists
|
||||
// Ensure directory exists with secure permissions
|
||||
const dir = dirname(dbPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
this.db = new Database(dbPath);
|
||||
// Harden database file permissions to prevent credential leakage
|
||||
try {
|
||||
chmodSync(dbPath, 0o600);
|
||||
} catch {
|
||||
// Ignore chmod failures (e.g., Windows)
|
||||
}
|
||||
this.initializeSchema();
|
||||
|
||||
this.insertStmt = this.db.prepare(
|
||||
|
||||
@@ -197,17 +197,24 @@ export abstract class OAuthCallbackFlow {
|
||||
});
|
||||
|
||||
// Manual input race (if supported)
|
||||
// Errors from manual input should not abort the flow - only successful input wins the race
|
||||
if (this.ctrl.onManualCodeInput) {
|
||||
const manualPromise = this.ctrl.onManualCodeInput().then((input): CallbackResult => {
|
||||
const parsed = parseCallbackInput(input);
|
||||
if (!parsed.code) {
|
||||
throw new Error("No authorization code found in input");
|
||||
}
|
||||
if (expectedState && parsed.state && parsed.state !== expectedState) {
|
||||
throw new Error("State mismatch - possible CSRF attack");
|
||||
}
|
||||
return { code: parsed.code, state: parsed.state ?? "" };
|
||||
});
|
||||
const manualPromise = this.ctrl
|
||||
.onManualCodeInput()
|
||||
.then((input): CallbackResult => {
|
||||
const parsed = parseCallbackInput(input);
|
||||
if (!parsed.code) {
|
||||
throw new Error("No authorization code found in input");
|
||||
}
|
||||
if (expectedState && parsed.state && parsed.state !== expectedState) {
|
||||
throw new Error("State mismatch - possible CSRF attack");
|
||||
}
|
||||
return { code: parsed.code, state: parsed.state ?? "" };
|
||||
})
|
||||
.catch((): Promise<CallbackResult> => {
|
||||
// On manual input error, wait forever - let callback or abort signal win
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
return Promise.race([callbackPromise, manualPromise]);
|
||||
}
|
||||
|
||||
@@ -174,20 +174,32 @@ async function pollForGitHubAccessToken(
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||
const err = (raw as DeviceTokenErrorResponse).error;
|
||||
if (err === "authorization_pending") {
|
||||
await abortableSleep(intervalMs, signal);
|
||||
try {
|
||||
await abortableSleep(intervalMs, signal);
|
||||
} catch {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (err === "slow_down") {
|
||||
intervalMs += 5000;
|
||||
await abortableSleep(intervalMs, signal);
|
||||
try {
|
||||
await abortableSleep(intervalMs, signal);
|
||||
} catch {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Device flow failed: ${err}`);
|
||||
}
|
||||
|
||||
await abortableSleep(intervalMs, signal);
|
||||
try {
|
||||
await abortableSleep(intervalMs, signal);
|
||||
} catch {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Device flow timed out");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* OpenAI Codex (ChatGPT OAuth) flow
|
||||
*/
|
||||
|
||||
import { OAuthCallbackFlow, parseCallbackInput } from "./callback-server";
|
||||
import { OAuthCallbackFlow } from "./callback-server";
|
||||
import { generatePKCE } from "./pkce";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
@@ -125,28 +125,8 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
|
||||
export async function loginOpenAICodex(ctrl: OAuthController): Promise<OAuthCredentials> {
|
||||
const pkce = await generatePKCE();
|
||||
const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
|
||||
const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
|
||||
try {
|
||||
return await flow.login();
|
||||
} catch (error) {
|
||||
if (!ctrl.onPrompt) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctrl.onProgress?.("Callback server failed, falling back to manual input");
|
||||
|
||||
const input = await ctrl.onPrompt({
|
||||
message: "Paste the authorization code (or full redirect URL):",
|
||||
});
|
||||
|
||||
const parsed = parseCallbackInput(input);
|
||||
if (!parsed.code) {
|
||||
throw new Error("No authorization code found in input");
|
||||
}
|
||||
|
||||
return exchangeCodeForToken(parsed.code, pkce.verifier, redirectUri);
|
||||
}
|
||||
return flow.login();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Fixed
|
||||
|
||||
- Fixed cache invalidation for streaming edits to prevent stale data
|
||||
- Fixed file existence check for prompt templates directory
|
||||
- Fixed bash output streaming to prevent premature stream closure
|
||||
- Fixed LSP client request handling when signal is already aborted
|
||||
- Fixed git apply operations with stdin input handling
|
||||
|
||||
### Security
|
||||
|
||||
- Updated Anthropic authentication to handle manual code input securely
|
||||
|
||||
## [6.8.1] - 2026-01-20
|
||||
|
||||
|
||||
@@ -454,15 +454,19 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
if (event.message.role === "toolResult") {
|
||||
const { $normative, toolCallId } = event.message as {
|
||||
const { toolName, $normative, toolCallId, details } = event.message as {
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
details?: unknown;
|
||||
details?: { path?: string };
|
||||
$normative?: Record<string, unknown>;
|
||||
};
|
||||
if ($normative && toolCallId && this.settingsManager.getNormativeRewrite()) {
|
||||
await this._rewriteToolCallArgs(toolCallId, $normative);
|
||||
}
|
||||
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
||||
if (toolName === "edit" && details?.path) {
|
||||
this._invalidateFileCacheForPath(details.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,11 +583,16 @@ export class AgentSession {
|
||||
this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - mark as empty string so we don't retry
|
||||
this._streamingEditFileCache.set(resolvedPath, "");
|
||||
// Don't cache on read errors - let the edit tool handle them
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate cache for a file after an edit completes to prevent stale data */
|
||||
private _invalidateFileCacheForPath(path: string): void {
|
||||
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
||||
this._streamingEditFileCache.delete(resolvedPath);
|
||||
}
|
||||
|
||||
private _maybeAbortStreamingEdit(event: AgentEvent): void {
|
||||
if (!this.settingsManager.getEditStreamingAbort()) return;
|
||||
if (this._streamingEditAbortTriggered) return;
|
||||
|
||||
@@ -560,7 +560,11 @@ export class AuthStorage {
|
||||
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
credentials = await loginAnthropic(ctrl);
|
||||
credentials = await loginAnthropic({
|
||||
...ctrl,
|
||||
onManualCodeInput: async () =>
|
||||
ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
|
||||
});
|
||||
break;
|
||||
case "github-copilot":
|
||||
credentials = await loginGitHubCopilot({
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
||||
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
|
||||
|
||||
const stream = new OutputSink({ onLine: options?.onChunk });
|
||||
const stream = new OutputSink({ onChunk: options?.onChunk });
|
||||
|
||||
const child = cspawn([shell, ...args, finalCommand], {
|
||||
cwd: options?.cwd,
|
||||
@@ -44,6 +44,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
||||
});
|
||||
|
||||
// Pump streams - errors during abort/timeout are expected
|
||||
// Use preventClose to avoid closing the shared sink when either stream finishes
|
||||
await Promise.allSettled([
|
||||
child.stdout.pipeTo(stream.createWritable()),
|
||||
child.stderr.pipeTo(stream.createWritable()),
|
||||
@@ -92,7 +93,7 @@ export async function executeBashWithOperations(
|
||||
operations: BashOperations,
|
||||
options?: BashExecutorOptions,
|
||||
): Promise<BashResult> {
|
||||
const stream = new OutputSink({ onLine: options?.onChunk });
|
||||
const stream = new OutputSink({ onChunk: options?.onChunk });
|
||||
const writable = stream.createWritable();
|
||||
const writer = writable.getWriter();
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ async function loadTemplatesFromDir(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!Bun.file(dir).exists()) {
|
||||
if (!(await Bun.file(dir).exists())) {
|
||||
return [];
|
||||
}
|
||||
logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) });
|
||||
|
||||
@@ -741,7 +741,7 @@ export async function sendRequest(
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
if (signal.aborted) {
|
||||
abortHandler();
|
||||
return;
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import type { EventBus } from "../../event-bus";
|
||||
import { callTool } from "../../mcp/client";
|
||||
import type { MCPManager } from "../../mcp/manager";
|
||||
import type { ModelRegistry } from "../../model-registry";
|
||||
import { formatModelString, parseModelPattern } from "../../model-resolver";
|
||||
import { checkPythonKernelAvailability } from "../../python-kernel";
|
||||
import type { ToolSession } from "..";
|
||||
import { LspTool } from "../lsp/index";
|
||||
import type { LspParams } from "../lsp/types";
|
||||
import { PythonTool } from "../python";
|
||||
import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
|
||||
import { resolveModelPattern } from "./model-resolver";
|
||||
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
||||
import {
|
||||
type AgentDefinition,
|
||||
@@ -296,10 +296,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
}
|
||||
|
||||
const serializedSettings = options.settingsManager?.serialize();
|
||||
const availableModels = options.modelRegistry?.getAvailable().map((model) => `${model.provider}/${model.id}`);
|
||||
const availableModels = options.modelRegistry?.getAvailable() ?? [];
|
||||
|
||||
// Resolve and add model
|
||||
const resolvedModel = await resolveModelPattern(modelOverride || agent.model, availableModels, serializedSettings);
|
||||
// Resolve model pattern to provider/modelId string
|
||||
const modelPattern = modelOverride ?? agent.model;
|
||||
let resolvedModel: string | undefined;
|
||||
if (modelPattern) {
|
||||
// Handle omp/<role> or pi/<role> aliases (e.g., "omp/slow", "pi/fast")
|
||||
let effectivePattern = modelPattern;
|
||||
const lower = modelPattern.toLowerCase();
|
||||
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
|
||||
const role = lower.startsWith("omp/") ? modelPattern.slice(4) : modelPattern.slice(3);
|
||||
const roles = serializedSettings?.modelRoles as Record<string, string> | undefined;
|
||||
const configured = roles?.[role] ?? roles?.[role.toLowerCase()];
|
||||
if (configured) {
|
||||
effectivePattern = configured;
|
||||
}
|
||||
}
|
||||
const { model } = parseModelPattern(effectivePattern, availableModels);
|
||||
resolvedModel = model ? formatModelString(model) : undefined;
|
||||
}
|
||||
const sessionFile = subtaskSessionFile ?? null;
|
||||
const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
|
||||
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Model resolution with fuzzy pattern matching.
|
||||
*
|
||||
* Returns models in "provider/modelId" format for use with --model flag.
|
||||
*
|
||||
* Supports:
|
||||
* - Exact match: "gpt-5.2" → "p-openai/gpt-5.2"
|
||||
* - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5"
|
||||
* - Comma fallback: "gpt, opus" → tries gpt first, then opus
|
||||
* - "default" → undefined (use system default)
|
||||
* - "omp/slow" or "pi/slow" → configured slow model from settings
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { type Settings as SettingsFile, settingsCapability } from "../../../capability/settings";
|
||||
import { loadCapability } from "../../../discovery";
|
||||
import type { Settings as SettingsData } from "../../settings-manager";
|
||||
import { resolveOmpCommand } from "./omp-command";
|
||||
|
||||
/** Cache for available models (provider/modelId format) */
|
||||
let cachedModels: string[] | null = null;
|
||||
|
||||
/** Cache expiry time (5 minutes) */
|
||||
let cacheExpiry = 0;
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Get available models from `omp --list-models`.
|
||||
* Returns models in "provider/modelId" format.
|
||||
* Caches the result for performance.
|
||||
*/
|
||||
export async function getAvailableModels(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
if (cachedModels !== null && now < cacheExpiry) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
try {
|
||||
const ompCommand = resolveOmpCommand();
|
||||
const result = await $`${ompCommand.cmd} ${ompCommand.args} --list-models`.quiet().nothrow();
|
||||
const stdout = result.stdout?.toString() ?? "";
|
||||
|
||||
if (result.exitCode !== 0 || !stdout.trim()) {
|
||||
cachedModels = [];
|
||||
cacheExpiry = now + CACHE_TTL_MS;
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
// Parse output: skip header line, extract provider/model
|
||||
const lines = stdout.trim().split("\n");
|
||||
cachedModels = lines
|
||||
.slice(1) // Skip header
|
||||
.map((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Format: provider/modelId
|
||||
return parts[0] && parts[1] ? `${parts[0]}/${parts[1]}` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
cacheExpiry = now + CACHE_TTL_MS;
|
||||
return cachedModels;
|
||||
} catch {
|
||||
cachedModels = [];
|
||||
cacheExpiry = now + CACHE_TTL_MS;
|
||||
return cachedModels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the model cache (for testing).
|
||||
*/
|
||||
export function clearModelCache(): void {
|
||||
cachedModels = null;
|
||||
cacheExpiry = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model roles from settings files using capability API.
|
||||
*/
|
||||
async function loadModelRoles(): Promise<Record<string, string>> {
|
||||
const result = await loadCapability<SettingsFile>(settingsCapability.id, { cwd: process.cwd() });
|
||||
|
||||
// Merge all settings, prioritizing first (highest priority)
|
||||
let modelRoles: Record<string, string> = {};
|
||||
for (const settings of result.items.reverse()) {
|
||||
const roles = settings.data.modelRoles as Record<string, string> | undefined;
|
||||
if (roles) {
|
||||
modelRoles = { ...modelRoles, ...roles };
|
||||
}
|
||||
}
|
||||
|
||||
return modelRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an omp/<role> alias to a model string.
|
||||
* Looks up the role in settings.modelRoles and returns the configured model.
|
||||
* Returns undefined if the role isn't configured.
|
||||
*/
|
||||
async function resolveOmpAlias(
|
||||
role: string,
|
||||
availableModels: string[],
|
||||
settings?: SettingsData,
|
||||
): Promise<string | undefined> {
|
||||
const roles = settings?.modelRoles ?? (await loadModelRoles());
|
||||
|
||||
// Look up role in settings (case-insensitive)
|
||||
const configured = roles[role] || roles[role.toLowerCase()];
|
||||
if (!configured) return undefined;
|
||||
|
||||
// configured is in "provider/modelId" format, find in available models
|
||||
return availableModels.find((m) => m.toLowerCase() === configured.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model ID from "provider/modelId" format.
|
||||
*/
|
||||
function getModelId(fullModel: string): string {
|
||||
const slashIdx = fullModel.indexOf("/");
|
||||
return slashIdx > 0 ? fullModel.slice(slashIdx + 1) : fullModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract provider from "provider/modelId" format.
|
||||
* Returns undefined if no provider prefix.
|
||||
*/
|
||||
function getProvider(fullModel: string): string | undefined {
|
||||
const slashIdx = fullModel.indexOf("/");
|
||||
return slashIdx > 0 ? fullModel.slice(0, slashIdx) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fuzzy model pattern to "provider/modelId" format.
|
||||
*
|
||||
* Supports comma-separated patterns (e.g., "gpt, opus") - tries each in order.
|
||||
* Returns undefined if pattern is "default", undefined, or no match found.
|
||||
*
|
||||
* @param pattern - Model pattern to resolve
|
||||
* @param availableModels - Optional pre-fetched list of available models (in provider/modelId format)
|
||||
* @param settings - Optional settings for role alias resolution (pi/..., omp/...)
|
||||
*/
|
||||
export async function resolveModelPattern(
|
||||
pattern: string | undefined,
|
||||
availableModels?: string[],
|
||||
settings?: SettingsData,
|
||||
): Promise<string | undefined> {
|
||||
if (!pattern || pattern === "default") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const models = availableModels ?? (await getAvailableModels());
|
||||
if (models.length === 0) {
|
||||
// Fallback: return pattern as-is if we can't get available models
|
||||
return pattern;
|
||||
}
|
||||
|
||||
// Split by comma, try each pattern in order
|
||||
const patterns = pattern
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const p of patterns) {
|
||||
// Handle omp/<role> or pi/<role> aliases - looks up role in settings.modelRoles
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
|
||||
const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
|
||||
const resolved = await resolveOmpAlias(role, models, settings);
|
||||
if (resolved) return resolved;
|
||||
continue; // Role not configured, try next pattern
|
||||
}
|
||||
|
||||
// Try exact match on full provider/modelId
|
||||
const exactFull = models.find((m) => m.toLowerCase() === p.toLowerCase());
|
||||
if (exactFull) return exactFull;
|
||||
|
||||
// Try exact match on model ID only
|
||||
const exactId = models.find((m) => getModelId(m).toLowerCase() === p.toLowerCase());
|
||||
if (exactId) return exactId;
|
||||
|
||||
// Check if pattern has provider prefix (e.g., "zai/glm-4.7")
|
||||
const patternProvider = getProvider(p);
|
||||
const patternModelId = getModelId(p);
|
||||
|
||||
// If pattern has provider prefix, fuzzy match must stay within that provider
|
||||
// (don't cross provider boundaries when user explicitly specifies provider)
|
||||
if (patternProvider) {
|
||||
const providerFuzzyMatch = models.find(
|
||||
(m) =>
|
||||
getProvider(m)?.toLowerCase() === patternProvider.toLowerCase() &&
|
||||
getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()),
|
||||
);
|
||||
if (providerFuzzyMatch) return providerFuzzyMatch;
|
||||
// No match in specified provider - don't fall through to other providers
|
||||
continue;
|
||||
}
|
||||
|
||||
// No provider prefix - fall back to general fuzzy match on model ID (substring)
|
||||
const fuzzyMatch = models.find((m) => getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()));
|
||||
if (fuzzyMatch) return fuzzyMatch;
|
||||
}
|
||||
|
||||
// No match found - use default model
|
||||
return undefined;
|
||||
}
|
||||
@@ -799,10 +799,11 @@ const reportFatal = async (message: string): Promise<void> => {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
|
||||
const runState = activeRun;
|
||||
if (runState) {
|
||||
runState.abortController.abort();
|
||||
runState.abortController.abort(error);
|
||||
if (runState.session) {
|
||||
void runState.session.abort();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { cspawn } from "@oh-my-pi/pi-utils";
|
||||
import type { FileSink } from "bun";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ensureTool } from "../../../utils/tools-manager";
|
||||
import type { RenderResult, SpecialHandler } from "./types";
|
||||
@@ -16,59 +15,17 @@ async function exec(
|
||||
args: string[],
|
||||
options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
|
||||
): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(options?.signal?.reason ?? new Error("Aborted"));
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
const timeoutId =
|
||||
options?.timeout && options.timeout > 0
|
||||
? setTimeout(() => controller.abort(new Error("Timeout")), options.timeout)
|
||||
: undefined;
|
||||
const proc = cspawn([cmd, ...args], {
|
||||
signal: controller.signal,
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout,
|
||||
stdin: options?.input ? Buffer.from(options.input) : undefined,
|
||||
});
|
||||
|
||||
if (options?.input && proc.stdin) {
|
||||
const stdin = proc.stdin as FileSink;
|
||||
const payload = typeof options.input === "string" ? new TextEncoder().encode(options.input) : options.input;
|
||||
stdin.write(payload);
|
||||
const flushed = stdin.flush();
|
||||
if (flushed instanceof Promise) {
|
||||
await flushed;
|
||||
}
|
||||
const ended = stdin.end();
|
||||
if (ended instanceof Promise) {
|
||||
await ended;
|
||||
}
|
||||
}
|
||||
|
||||
const [stdout, stderr, exitResult] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
(async () => {
|
||||
try {
|
||||
await proc.exited;
|
||||
return proc.exitCode ?? 0;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "exitCode" in err) {
|
||||
const exitValue = (err as { exitCode?: number }).exitCode;
|
||||
if (typeof exitValue === "number") {
|
||||
return exitValue;
|
||||
}
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
})(),
|
||||
proc.stdout.text(),
|
||||
proc.stderr.text(),
|
||||
proc.exited.then(() => proc.exitCode ?? 0),
|
||||
]);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { WorktreeError, WorktreeErrorCode } from "./errors";
|
||||
import { git, gitWithStdin } from "./git";
|
||||
import { git, gitWithInput } from "./git";
|
||||
import { find, remove, type Worktree } from "./operations";
|
||||
|
||||
export type CollapseStrategy = "simple" | "merge-base" | "rebase";
|
||||
@@ -121,10 +121,10 @@ async function collapseRebase(src: Worktree, dst: Worktree): Promise<string> {
|
||||
}
|
||||
|
||||
async function applyDiff(diff: string, targetPath: string): Promise<void> {
|
||||
let result = await gitWithStdin(["apply"], diff, targetPath);
|
||||
let result = await gitWithInput(["apply"], diff, targetPath);
|
||||
if (result.code === 0) return;
|
||||
|
||||
result = await gitWithStdin(["apply", "--3way"], diff, targetPath);
|
||||
result = await gitWithInput(["apply", "--3way"], diff, targetPath);
|
||||
if (result.code === 0) return;
|
||||
|
||||
throw new WorktreeError(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as path from "node:path";
|
||||
import type { Subprocess } from "bun";
|
||||
import { ptree } from "@oh-my-pi/pi-utils";
|
||||
import { execCommand } from "../../core/exec";
|
||||
import { WorktreeError, WorktreeErrorCode } from "./errors";
|
||||
|
||||
@@ -9,32 +9,6 @@ export interface GitResult {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
type WritableLike = {
|
||||
write: (chunk: string | Uint8Array) => unknown;
|
||||
flush?: () => unknown;
|
||||
end?: () => unknown;
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
async function writeStdin(handle: unknown, stdin: string): Promise<void> {
|
||||
if (!handle || typeof handle === "number") return;
|
||||
if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
|
||||
const writer = (handle as WritableStream<Uint8Array>).getWriter();
|
||||
try {
|
||||
await writer.write(textEncoder.encode(stdin));
|
||||
} finally {
|
||||
await writer.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sink = handle as WritableLike;
|
||||
sink.write(stdin);
|
||||
if (sink.flush) sink.flush();
|
||||
if (sink.end) sink.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command.
|
||||
* @param args - Command arguments (excluding 'git')
|
||||
@@ -50,23 +24,15 @@ export async function git(args: string[], cwd?: string): Promise<GitResult> {
|
||||
* Execute git command with stdin input.
|
||||
* Used for piping diffs to `git apply`.
|
||||
*/
|
||||
export async function gitWithStdin(args: string[], stdin: string, cwd?: string): Promise<GitResult> {
|
||||
const proc: Subprocess = Bun.spawn(["git", ...args], {
|
||||
export async function gitWithInput(args: string[], stdin: string, cwd?: string): Promise<GitResult> {
|
||||
const proc = ptree.cspawn(["git", ...args], {
|
||||
cwd: cwd ?? process.cwd(),
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: Buffer.from(stdin),
|
||||
});
|
||||
|
||||
await writeStdin(proc.stdin, stdin);
|
||||
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
(proc.stdout as ReadableStream<Uint8Array>).text(),
|
||||
(proc.stderr as ReadableStream<Uint8Array>).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return { code: exitCode ?? 0, stdout, stderr };
|
||||
return { code: proc.exitCode ?? 0, stdout, stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { type CollapseOptions, type CollapseResult, type CollapseStrategy, collapse } from "./collapse";
|
||||
export { WORKTREE_BASE } from "./constants";
|
||||
export { WorktreeError, WorktreeErrorCode } from "./errors";
|
||||
export { getRepoName, getRepoRoot, git, gitWithStdin } from "./git";
|
||||
export { getRepoName, getRepoRoot, git, gitWithInput as gitWithStdin } from "./git";
|
||||
export { create, find, list, prune, remove, type Worktree, which } from "./operations";
|
||||
export {
|
||||
cleanupSessions,
|
||||
|
||||
@@ -32,8 +32,6 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
const timeout = Bun.sleep(3000).then(() => Promise.reject(new Error("Clipboard operation timed out")));
|
||||
|
||||
let promise: Promise<void>;
|
||||
try {
|
||||
switch (platform()) {
|
||||
@@ -56,11 +54,11 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to copy to clipboard: ${error.message}`);
|
||||
throw new Error(`Failed to copy to clipboard: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw new Error(`Failed to copy to clipboard: ${String(error)}`);
|
||||
throw new Error(`Failed to copy to clipboard: ${String(error)}`, { cause: error });
|
||||
}
|
||||
await Promise.race([promise, timeout]);
|
||||
await Promise.race([promise, Bun.sleep(3000)]);
|
||||
}
|
||||
|
||||
export interface ClipboardImage {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { clearModelCache, resolveModelPattern } from "../src/core/tools/task/model-resolver";
|
||||
|
||||
describe("task/model-resolver: resolveModelPattern", () => {
|
||||
afterEach(() => {
|
||||
clearModelCache();
|
||||
});
|
||||
|
||||
describe("provider-specific matching", () => {
|
||||
const models = ["cerebras/zai-glm-4.7", "zai/glm-4.7", "anthropic/claude-sonnet"];
|
||||
|
||||
test("exact full match with provider prefix", async () => {
|
||||
const result = await resolveModelPattern("zai/glm-4.7", models);
|
||||
expect(result).toBe("zai/glm-4.7");
|
||||
});
|
||||
|
||||
test("explicit provider should not cross provider boundaries", async () => {
|
||||
const modelsWithoutZai = ["cerebras/zai-glm-4.7", "zai/glm-4.6"];
|
||||
const result = await resolveModelPattern("zai/glm-4.7", modelsWithoutZai);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fuzzy match within explicit provider", async () => {
|
||||
const modelsWithSuffix = ["cerebras/zai-glm-4.7-preview", "zai/glm-4.7-beta"];
|
||||
const result = await resolveModelPattern("zai/glm-4.7", modelsWithSuffix);
|
||||
expect(result).toBe("zai/glm-4.7-beta");
|
||||
});
|
||||
|
||||
test("exact ID match without provider", async () => {
|
||||
const result = await resolveModelPattern("glm-4.7", models);
|
||||
expect(result).toBe("zai/glm-4.7");
|
||||
});
|
||||
|
||||
test("fuzzy match without provider uses general fallback", async () => {
|
||||
const modelsNoExact = ["cerebras/zai-glm-4.7", "anthropic/claude-sonnet"];
|
||||
const result = await resolveModelPattern("glm", modelsNoExact);
|
||||
expect(result).toBe("cerebras/zai-glm-4.7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("comma-separated patterns (fallback chain)", () => {
|
||||
test("first pattern fails, second succeeds", async () => {
|
||||
const models = ["cerebras/zai-glm-4.7", "anthropic/claude-sonnet"];
|
||||
const result = await resolveModelPattern("zai/glm-4.6, glm", models);
|
||||
expect(result).toBe("cerebras/zai-glm-4.7");
|
||||
});
|
||||
|
||||
test("first pattern succeeds, second ignored", async () => {
|
||||
const models = ["zai/glm-4.7", "cerebras/zai-glm-4.7"];
|
||||
const result = await resolveModelPattern("zai/glm-4.7, cerebras/zai-glm-4.7", models);
|
||||
expect(result).toBe("zai/glm-4.7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("default returns undefined", async () => {
|
||||
const result = await resolveModelPattern("default", ["zai/glm-4.7"]);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("undefined returns undefined", async () => {
|
||||
const result = await resolveModelPattern(undefined, ["zai/glm-4.7"]);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("empty models list returns pattern as-is", async () => {
|
||||
const result = await resolveModelPattern("zai/glm-4.7", []);
|
||||
expect(result).toBe("zai/glm-4.7");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,8 @@ function registerManaged(child: PipedSubprocess): void {
|
||||
});
|
||||
}
|
||||
|
||||
// A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup).
|
||||
type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">;
|
||||
// A Bun subprocess with stdin=Writable/ignore, stdout/stderr=pipe (for tracking/cleanup).
|
||||
type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
|
||||
|
||||
/**
|
||||
* ChildProcess wraps a managed subprocess, capturing output, errors, and providing
|
||||
@@ -343,7 +343,10 @@ export class TimeoutError extends AbortError {
|
||||
/**
|
||||
* Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
|
||||
*/
|
||||
type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">, "stdout" | "stderr"> & {
|
||||
type ChildSpawnOptions = Omit<
|
||||
Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | null, "pipe", "pipe">,
|
||||
"stdout" | "stderr"
|
||||
> & {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -355,6 +358,7 @@ type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">,
|
||||
export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
|
||||
const { timeout, ...rest } = options ?? {};
|
||||
const child = spawn(cmd, {
|
||||
stdin: "ignore",
|
||||
...rest,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
|
||||
@@ -81,7 +81,7 @@ interface Args {
|
||||
function parseArguments(): Args {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"react-dir": { type: "string", default: DEFAULT_REACT_DIR },
|
||||
"react-dir": { type: "string", default: DEFAULT_REACT_DIR.path },
|
||||
output: { type: "string", default: DEFAULT_OUTPUT },
|
||||
"count-per-type": { type: "string", default: "20" },
|
||||
seed: { type: "string", default: "42" },
|
||||
@@ -93,7 +93,7 @@ function parseArguments(): Args {
|
||||
});
|
||||
|
||||
return {
|
||||
reactDir: values["react-dir"] ?? DEFAULT_REACT_DIR,
|
||||
reactDir: values["react-dir"] ?? DEFAULT_REACT_DIR.path,
|
||||
output: values.output ?? DEFAULT_OUTPUT,
|
||||
countPerType: parseInt(values["count-per-type"] ?? "20", 10),
|
||||
seed: parseInt(values.seed ?? "42", 10),
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { createTempDir } from "@oh-my-pi/pi-utils";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { generateJsonReport, generateReport } from "./report";
|
||||
import { runBenchmark, type BenchmarkConfig, type ProgressEvent } from "./runner";
|
||||
|
||||
Reference in New Issue
Block a user