From 81ad827380eb6128f94c5891da48ef6a695966e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 12:14:32 +0100 Subject: [PATCH] refactor(plugin-sdk): extract shared dedupe helpers --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- src/plugin-sdk/browser-config.ts | 1 + src/plugin-sdk/browser-maintenance.ts | 141 +----------- src/plugin-sdk/browser-trash.ts | 148 ++++++++++++ src/plugin-sdk/migration.ts | 172 ++++++++++++++ src/plugin-sdk/provider-auth.ts | 169 ++++++++++++++ src/plugin-sdk/tool-payload.ts | 212 ++++++++++++++++++ 7 files changed, 705 insertions(+), 142 deletions(-) create mode 100644 src/plugin-sdk/browser-trash.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 903fe6c3357..0596c331aad 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -244286f93cd42484f81061b672437d7a769a61864c72eb3795f9e7abc739f60b plugin-sdk-api-baseline.json -5b7e45d83a0a7862f26f59a32647cdb04289609419e61473284568dd3adf9736 plugin-sdk-api-baseline.jsonl +c14ed336d7add0044299560f2fb2fa9272f23aae335799313f32c63521edc24e plugin-sdk-api-baseline.json +e096b25bd16bf1b0562a783609e9f7d945b6e29560ef8ad3fb433145fe084a5d plugin-sdk-api-baseline.jsonl diff --git a/src/plugin-sdk/browser-config.ts b/src/plugin-sdk/browser-config.ts index bdc4a7b232a..890e9d97be4 100644 --- a/src/plugin-sdk/browser-config.ts +++ b/src/plugin-sdk/browser-config.ts @@ -15,4 +15,5 @@ export { } from "./browser-profiles.js"; export { parseBrowserHttpUrl, redactCdpUrl } from "./browser-cdp.js"; export { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./browser-control-auth.js"; +export { movePathToTrash, type MovePathToTrashOptions } from "./browser-trash.js"; export type { BrowserControlAuth } from "./browser-control-auth.js"; diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index 04a869f387a..9bc8236e6ef 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -1,7 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; +export { movePathToTrash, type MovePathToTrashOptions } from "./browser-trash.js"; type CloseTrackedBrowserTabsParams = { sessionKeys: Array; @@ -14,8 +12,6 @@ type BrowserMaintenanceSurface = { }; let cachedBrowserMaintenanceSurface: BrowserMaintenanceSurface | undefined; -const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]); -const TRASH_DESTINATION_RETRY_LIMIT = 4; function hasRequestedSessionKeys(sessionKeys: Array): boolean { return sessionKeys.some((key) => Boolean(key?.trim())); @@ -30,125 +26,6 @@ function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface { return cachedBrowserMaintenanceSurface; } -function getFsErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== "object" || !("code" in error)) { - return undefined; - } - const code = (error as NodeJS.ErrnoException).code; - return typeof code === "string" ? code : undefined; -} - -function isTrashDestinationCollision(error: unknown): boolean { - const code = getFsErrorCode(error); - return Boolean(code && TRASH_DESTINATION_COLLISION_CODES.has(code)); -} - -function isSameOrChildPath(candidate: string, parent: string): boolean { - return candidate === parent || candidate.startsWith(`${parent}${path.sep}`); -} - -function resolveAllowedTrashRoots(): string[] { - const roots = [os.homedir(), os.tmpdir()].map((root) => { - try { - return path.resolve(fs.realpathSync.native(root)); - } catch { - return path.resolve(root); - } - }); - return [...new Set(roots)]; -} - -function assertAllowedTrashTarget(targetPath: string): void { - let resolvedTargetPath = path.resolve(targetPath); - try { - resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath)); - } catch { - // The subsequent move will surface missing or inaccessible targets. - } - const isAllowed = resolveAllowedTrashRoots().some( - (root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root), - ); - if (!isAllowed) { - throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`); - } -} - -function resolveTrashDir(): string { - const homeDir = os.homedir(); - const trashDir = path.join(homeDir, ".Trash"); - fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 }); - const trashDirStat = fs.lstatSync(trashDir); - if (!trashDirStat.isDirectory() || trashDirStat.isSymbolicLink()) { - throw new Error(`Refusing to use non-directory/symlink trash directory: ${trashDir}`); - } - const realHome = path.resolve(fs.realpathSync.native(homeDir)); - const resolvedTrashDir = path.resolve(fs.realpathSync.native(trashDir)); - if (resolvedTrashDir === realHome || !isSameOrChildPath(resolvedTrashDir, realHome)) { - throw new Error(`Trash directory escaped home directory: ${trashDir}`); - } - return resolvedTrashDir; -} - -function trashBaseName(targetPath: string): string { - const resolvedTargetPath = path.resolve(targetPath); - if (resolvedTargetPath === path.parse(resolvedTargetPath).root) { - throw new Error(`Refusing to trash root path: ${targetPath}`); - } - const base = path.basename(resolvedTargetPath).replace(/[\\/]+/g, ""); - if (!base) { - throw new Error(`Unable to derive safe trash basename for: ${targetPath}`); - } - return base; -} - -function resolveContainedPath(root: string, leaf: string): string { - const resolvedRoot = path.resolve(root); - const resolvedPath = path.resolve(resolvedRoot, leaf); - if (!isSameOrChildPath(resolvedPath, resolvedRoot) || resolvedPath === resolvedRoot) { - throw new Error(`Trash destination escaped trash directory: ${resolvedPath}`); - } - return resolvedPath; -} - -function reserveTrashDestination(trashDir: string, base: string, timestamp: number): string { - const containerPrefix = resolveContainedPath(trashDir, `${base}-${timestamp}-`); - const container = fs.mkdtempSync(containerPrefix); - const resolvedContainer = path.resolve(container); - const resolvedTrashDir = path.resolve(trashDir); - if ( - resolvedContainer === resolvedTrashDir || - !isSameOrChildPath(resolvedContainer, resolvedTrashDir) - ) { - throw new Error(`Trash destination escaped trash directory: ${container}`); - } - return resolveContainedPath(container, base); -} - -function movePathToDestination(targetPath: string, dest: string): boolean { - try { - fs.renameSync(targetPath, dest); - return true; - } catch (error) { - if (getFsErrorCode(error) !== "EXDEV") { - if (isTrashDestinationCollision(error)) { - return false; - } - throw error; - } - } - - try { - fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true }); - fs.rmSync(targetPath, { recursive: true, force: false }); - return true; - } catch (error) { - if (isTrashDestinationCollision(error)) { - return false; - } - throw error; - } -} - export async function closeTrackedBrowserTabsForSessions( params: CloseTrackedBrowserTabsParams, ): Promise { @@ -165,19 +42,3 @@ export async function closeTrackedBrowserTabsForSessions( } return await surface.closeTrackedBrowserTabsForSessions(params); } - -export async function movePathToTrash(targetPath: string): Promise { - // Avoid resolving external trash helpers through the service PATH during cleanup. - const base = trashBaseName(targetPath); - assertAllowedTrashTarget(targetPath); - const trashDir = resolveTrashDir(); - const timestamp = Date.now(); - for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) { - const dest = reserveTrashDestination(trashDir, base, timestamp); - if (movePathToDestination(targetPath, dest)) { - return dest; - } - } - - throw new Error(`Unable to choose a unique trash destination for ${targetPath}`); -} diff --git a/src/plugin-sdk/browser-trash.ts b/src/plugin-sdk/browser-trash.ts new file mode 100644 index 00000000000..1057102b9c9 --- /dev/null +++ b/src/plugin-sdk/browser-trash.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type MovePathToTrashOptions = { + allowedRoots?: Iterable; +}; + +const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]); +const TRASH_DESTINATION_RETRY_LIMIT = 4; + +function getFsErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) { + return undefined; + } + const code = (error as NodeJS.ErrnoException).code; + return typeof code === "string" ? code : undefined; +} + +function isTrashDestinationCollision(error: unknown): boolean { + const code = getFsErrorCode(error); + return Boolean(code && TRASH_DESTINATION_COLLISION_CODES.has(code)); +} + +function isSameOrChildPath(candidate: string, parent: string): boolean { + return candidate === parent || candidate.startsWith(`${parent}${path.sep}`); +} + +function resolveAllowedTrashRoots(allowedRoots?: Iterable): string[] { + const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].map((root) => { + try { + return path.resolve(fs.realpathSync.native(root)); + } catch { + return path.resolve(root); + } + }); + return [...new Set(roots)]; +} + +function assertAllowedTrashTarget(targetPath: string, allowedRoots?: Iterable): void { + let resolvedTargetPath = path.resolve(targetPath); + try { + resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath)); + } catch { + // The subsequent move will surface missing or inaccessible targets. + } + const isAllowed = resolveAllowedTrashRoots(allowedRoots).some( + (root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root), + ); + if (!isAllowed) { + throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`); + } +} + +function resolveTrashDir(): string { + const homeDir = os.homedir(); + const trashDir = path.join(homeDir, ".Trash"); + fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 }); + const trashDirStat = fs.lstatSync(trashDir); + if (!trashDirStat.isDirectory() || trashDirStat.isSymbolicLink()) { + throw new Error(`Refusing to use non-directory/symlink trash directory: ${trashDir}`); + } + const realHome = path.resolve(fs.realpathSync.native(homeDir)); + const resolvedTrashDir = path.resolve(fs.realpathSync.native(trashDir)); + if (resolvedTrashDir === realHome || !isSameOrChildPath(resolvedTrashDir, realHome)) { + throw new Error(`Trash directory escaped home directory: ${trashDir}`); + } + return resolvedTrashDir; +} + +function trashBaseName(targetPath: string): string { + const resolvedTargetPath = path.resolve(targetPath); + if (resolvedTargetPath === path.parse(resolvedTargetPath).root) { + throw new Error(`Refusing to trash root path: ${targetPath}`); + } + const base = path.basename(resolvedTargetPath).replace(/[\\/]+/g, ""); + if (!base) { + throw new Error(`Unable to derive safe trash basename for: ${targetPath}`); + } + return base; +} + +function resolveContainedPath(root: string, leaf: string): string { + const resolvedRoot = path.resolve(root); + const resolvedPath = path.resolve(resolvedRoot, leaf); + if (!isSameOrChildPath(resolvedPath, resolvedRoot) || resolvedPath === resolvedRoot) { + throw new Error(`Trash destination escaped trash directory: ${resolvedPath}`); + } + return resolvedPath; +} + +function reserveTrashDestination(trashDir: string, base: string, timestamp: number): string { + const containerPrefix = resolveContainedPath(trashDir, `${base}-${timestamp}-`); + const container = fs.mkdtempSync(containerPrefix); + const resolvedContainer = path.resolve(container); + const resolvedTrashDir = path.resolve(trashDir); + if ( + resolvedContainer === resolvedTrashDir || + !isSameOrChildPath(resolvedContainer, resolvedTrashDir) + ) { + throw new Error(`Trash destination escaped trash directory: ${container}`); + } + return resolveContainedPath(container, base); +} + +function movePathToDestination(targetPath: string, dest: string): boolean { + try { + fs.renameSync(targetPath, dest); + return true; + } catch (error) { + if (getFsErrorCode(error) !== "EXDEV") { + if (isTrashDestinationCollision(error)) { + return false; + } + throw error; + } + } + + try { + fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true }); + fs.rmSync(targetPath, { recursive: true, force: false }); + return true; + } catch (error) { + if (isTrashDestinationCollision(error)) { + return false; + } + throw error; + } +} + +export async function movePathToTrash( + targetPath: string, + options: MovePathToTrashOptions = {}, +): Promise { + // Avoid resolving external trash helpers through the service PATH during cleanup. + const base = trashBaseName(targetPath); + assertAllowedTrashTarget(targetPath, options.allowedRoots); + const trashDir = resolveTrashDir(); + const timestamp = Date.now(); + for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) { + const dest = reserveTrashDestination(trashDir, base, timestamp); + if (movePathToDestination(targetPath, dest)) { + return dest; + } + } + + throw new Error(`Unable to choose a unique trash destination for ${targetPath}`); +} diff --git a/src/plugin-sdk/migration.ts b/src/plugin-sdk/migration.ts index 2e68eb8bb7e..1ce1785dd06 100644 --- a/src/plugin-sdk/migration.ts +++ b/src/plugin-sdk/migration.ts @@ -96,6 +96,178 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +export type MigrationConfigPatchDetails = { + path: string[]; + value: unknown; +}; + +class MigrationConfigPatchConflictError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "MigrationConfigPatchConflictError"; + } +} + +export function readMigrationConfigPath( + root: Record, + path: readonly string[], +): unknown { + let current: unknown = root; + for (const segment of path) { + if (!isRecord(current)) { + return undefined; + } + current = current[segment]; + } + return current; +} + +export function mergeMigrationConfigValue(left: unknown, right: unknown): unknown { + if (!isRecord(left) || !isRecord(right)) { + return structuredClone(right); + } + const next: Record = { ...left }; + for (const [key, value] of Object.entries(right)) { + next[key] = mergeMigrationConfigValue(next[key], value); + } + return next; +} + +export function writeMigrationConfigPath( + root: Record, + path: readonly string[], + value: unknown, +): void { + let current = root; + for (const segment of path.slice(0, -1)) { + const existing = current[segment]; + if (!isRecord(existing)) { + current[segment] = {}; + } + current = current[segment] as Record; + } + const leaf = path.at(-1); + if (!leaf) { + return; + } + current[leaf] = mergeMigrationConfigValue(current[leaf], value); +} + +export function hasMigrationConfigPatchConflict( + config: MigrationProviderContext["config"], + path: readonly string[], + value: unknown, +): boolean { + if (!isRecord(value)) { + return readMigrationConfigPath(config as Record, path) !== undefined; + } + const existing = readMigrationConfigPath(config as Record, path); + if (!isRecord(existing)) { + return false; + } + return Object.keys(value).some((key) => existing[key] !== undefined); +} + +export function createMigrationConfigPatchItem(params: { + id: string; + target: string; + path: string[]; + value: unknown; + message: string; + conflict?: boolean; + reason?: string; + source?: string; + details?: Record; +}): MigrationItem { + return createMigrationItem({ + id: params.id, + kind: "config", + action: "merge", + source: params.source, + target: params.target, + status: params.conflict ? "conflict" : "planned", + reason: params.conflict ? (params.reason ?? MIGRATION_REASON_TARGET_EXISTS) : undefined, + message: params.message, + details: { ...params.details, path: params.path, value: params.value }, + }); +} + +export function createMigrationManualItem(params: { + id: string; + source: string; + message: string; + recommendation: string; +}): MigrationItem { + return createMigrationItem({ + id: params.id, + kind: "manual", + action: "manual", + source: params.source, + status: "skipped", + message: params.message, + reason: params.recommendation, + }); +} + +export function readMigrationConfigPatchDetails( + item: MigrationItem, +): MigrationConfigPatchDetails | undefined { + const path = item.details?.path; + if ( + !Array.isArray(path) || + !path.every((segment): segment is string => typeof segment === "string") + ) { + return undefined; + } + return { path, value: item.details?.value }; +} + +export async function applyMigrationConfigPatchItem( + ctx: MigrationProviderContext, + item: MigrationItem, +): Promise { + if (item.status !== "planned") { + return item; + } + const details = readMigrationConfigPatchDetails(item); + if (!details) { + return markMigrationItemError(item, "missing config patch"); + } + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return markMigrationItemError(item, "config runtime unavailable"); + } + try { + const currentConfig = configApi.current() as MigrationProviderContext["config"]; + if ( + !ctx.overwrite && + hasMigrationConfigPatchConflict(currentConfig, details.path, details.value) + ) { + return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); + } + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + if (!ctx.overwrite && hasMigrationConfigPatchConflict(draft, details.path, details.value)) { + throw new MigrationConfigPatchConflictError(MIGRATION_REASON_TARGET_EXISTS); + } + writeMigrationConfigPath(draft as Record, details.path, details.value); + }, + }); + return { ...item, status: "migrated" }; + } catch (err) { + if (err instanceof MigrationConfigPatchConflictError) { + return markMigrationItemConflict(item, err.reason); + } + return markMigrationItemError(item, err instanceof Error ? err.message : String(err)); + } +} + +export function applyMigrationManualItem(item: MigrationItem): MigrationItem { + return markMigrationItemSkipped(item, item.reason ?? "manual follow-up required"); +} + function isSecretReferenceLike(value: unknown): boolean { if (!isRecord(value)) { return false; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index e647a457c8d..00f95adb21d 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -1,5 +1,6 @@ // Public auth/onboarding helpers for provider plugins. +import path from "node:path"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js"; import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js"; @@ -7,6 +8,10 @@ import { listProfilesForProvider } from "../agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; import { resolveEnvApiKey } from "../agents/model-auth-env.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { resolveProviderEndpoint } from "./provider-model-shared.js"; export type { OpenClawConfig } from "../config/config.js"; export type { SecretInput } from "../config/types.secrets.js"; @@ -87,6 +92,170 @@ export { hasUsableOAuthCredential, } from "../agents/auth-profiles/credential-state.js"; +const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; + +export const COPILOT_EDITOR_VERSION = "vscode/1.96.2"; +export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; +export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0"; +export const COPILOT_GITHUB_API_VERSION = "2025-04-01"; +export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; + +export type CachedCopilotToken = { + token: string; + expiresAt: number; + updatedAt: number; +}; + +export function buildCopilotIdeHeaders( + params: { + includeApiVersion?: boolean; + } = {}, +): Record { + return { + "Editor-Version": COPILOT_EDITOR_VERSION, + "Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION, + "User-Agent": COPILOT_USER_AGENT, + ...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}), + }; +} + +function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { + return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json"); +} + +function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { + return cache.expiresAt - now > 5 * 60 * 1000; +} + +function parseCopilotTokenResponse(value: unknown): { + token: string; + expiresAt: number; +} { + if (!value || typeof value !== "object") { + throw new Error("Unexpected response from GitHub Copilot token endpoint"); + } + const asRecord = value as Record; + const token = asRecord.token; + const expiresAt = asRecord.expires_at; + if (typeof token !== "string" || token.trim().length === 0) { + throw new Error("Copilot token response missing token"); + } + + let expiresAtMs: number; + if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { + expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt; + } else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) { + const parsed = Number.parseInt(expiresAt, 10); + if (!Number.isFinite(parsed)) { + throw new Error("Copilot token response has invalid expires_at"); + } + expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed; + } else { + throw new Error("Copilot token response missing expires_at"); + } + + return { token, expiresAt: expiresAtMs }; +} + +function resolveCopilotProxyHost(proxyEp: string): string | null { + const trimmed = proxyEp.trim(); + if (!trimmed) { + return null; + } + + const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const url = new URL(urlText); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return normalizeLowercaseStringOrEmpty(url.hostname); + } catch { + return null; + } +} + +export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) { + return null; + } + + const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i); + const proxyEp = match?.[1]?.trim(); + if (!proxyEp) { + return null; + } + + const proxyHost = resolveCopilotProxyHost(proxyEp); + if (!proxyHost) { + return null; + } + const host = proxyHost.replace(/^proxy\./i, "api."); + + const baseUrl = `https://${host}`; + return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl; +} + +export async function resolveCopilotApiToken(params: { + githubToken: string; + env?: NodeJS.ProcessEnv; + fetchImpl?: typeof fetch; + cachePath?: string; + loadJsonFileImpl?: (path: string) => unknown; + saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void; +}): Promise<{ + token: string; + expiresAt: number; + source: string; + baseUrl: string; +}> { + const env = params.env ?? process.env; + const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env); + const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile; + const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile; + const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined; + if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") { + if (isCopilotTokenUsable(cached)) { + return { + token: cached.token, + expiresAt: cached.expiresAt, + source: `cache:${cachePath}`, + baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL, + }; + } + } + + const fetchImpl = params.fetchImpl ?? fetch; + const res = await fetchImpl(COPILOT_TOKEN_URL, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.githubToken}`, + ...buildCopilotIdeHeaders({ includeApiVersion: true }), + }, + }); + + if (!res.ok) { + throw new Error(`Copilot token exchange failed: HTTP ${res.status}`); + } + + const json = parseCopilotTokenResponse(await res.json()); + const payload: CachedCopilotToken = { + token: json.token, + expiresAt: json.expiresAt, + updatedAt: Date.now(), + }; + saveJsonFileFn(cachePath, payload); + + return { + token: payload.token, + expiresAt: payload.expiresAt, + source: `fetched:${COPILOT_TOKEN_URL}`, + baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL, + }; +} + export function isProviderApiKeyConfigured(params: { provider: string; agentDir?: string; diff --git a/src/plugin-sdk/tool-payload.ts b/src/plugin-sdk/tool-payload.ts index 3aebcd48fe2..417b1244f81 100644 --- a/src/plugin-sdk/tool-payload.ts +++ b/src/plugin-sdk/tool-payload.ts @@ -41,3 +41,215 @@ export function extractToolPayload(result: ToolPayloadCarrier | null | undefined return text; } } + +export type PlainTextToolCallBlock = { + arguments: Record; + end: number; + name: string; + raw: string; + start: number; +}; + +export type PlainTextToolCallParseOptions = { + allowedToolNames?: Iterable; + maxPayloadBytes?: number; +}; + +const DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES = 256_000; +const END_TOOL_REQUEST = "[END_TOOL_REQUEST]"; + +function isToolNameChar(char: string | undefined): boolean { + return Boolean(char && /[A-Za-z0-9_-]/.test(char)); +} + +function skipHorizontalWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && (text[index] === " " || text[index] === "\t")) { + index += 1; + } + return index; +} + +function skipWhitespace(text: string, start: number): number { + let index = start; + while (index < text.length && /\s/.test(text[index] ?? "")) { + index += 1; + } + return index; +} + +function consumeLineBreak(text: string, start: number): number | null { + if (text[start] === "\r") { + return text[start + 1] === "\n" ? start + 2 : start + 1; + } + if (text[start] === "\n") { + return start + 1; + } + return null; +} + +function parseOpening(text: string, start: number): { end: number; name: string } | null { + if (text[start] !== "[") { + return null; + } + let cursor = start + 1; + const nameStart = cursor; + while (isToolNameChar(text[cursor])) { + cursor += 1; + } + if (cursor === nameStart || text[cursor] !== "]") { + return null; + } + const name = text.slice(nameStart, cursor); + cursor += 1; + cursor = skipHorizontalWhitespace(text, cursor); + const afterLineBreak = consumeLineBreak(text, cursor); + if (afterLineBreak === null) { + return null; + } + return { end: afterLineBreak, name }; +} + +function consumeJsonObject( + text: string, + start: number, + maxPayloadBytes: number, +): { end: number; value: Record } | null { + const cursor = skipWhitespace(text, start); + if (text[cursor] !== "{") { + return null; + } + let depth = 0; + let inString = false; + let escaped = false; + for (let index = cursor; index < text.length; index += 1) { + const char = text[index]; + if (index + 1 - cursor > maxPayloadBytes) { + return null; + } + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + const rawJson = text.slice(cursor, index + 1); + try { + const parsed = JSON.parse(rawJson) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return { end: index + 1, value: parsed as Record }; + } catch { + return null; + } + } + } + } + return null; +} + +function parseClosing(text: string, start: number, name: string): number | null { + const cursor = skipWhitespace(text, start); + if (text.startsWith(END_TOOL_REQUEST, cursor)) { + return cursor + END_TOOL_REQUEST.length; + } + const namedClosing = `[/${name}]`; + if (text.startsWith(namedClosing, cursor)) { + return cursor + namedClosing.length; + } + return null; +} + +function parsePlainTextToolCallBlockAt( + text: string, + start: number, + options?: PlainTextToolCallParseOptions, +): PlainTextToolCallBlock | null { + const opening = parseOpening(text, start); + if (!opening) { + return null; + } + const allowedToolNames = options?.allowedToolNames + ? new Set(options.allowedToolNames) + : undefined; + if (allowedToolNames && !allowedToolNames.has(opening.name)) { + return null; + } + const payload = consumeJsonObject( + text, + opening.end, + options?.maxPayloadBytes ?? DEFAULT_MAX_PLAIN_TEXT_TOOL_PAYLOAD_BYTES, + ); + if (!payload) { + return null; + } + const end = parseClosing(text, payload.end, opening.name); + if (end === null) { + return null; + } + return { + arguments: payload.value, + end, + name: opening.name, + raw: text.slice(start, end), + start, + }; +} + +export function parseStandalonePlainTextToolCallBlocks( + text: string, + options?: PlainTextToolCallParseOptions, +): PlainTextToolCallBlock[] | null { + const blocks: PlainTextToolCallBlock[] = []; + let cursor = skipWhitespace(text, 0); + while (cursor < text.length) { + const block = parsePlainTextToolCallBlockAt(text, cursor, options); + if (!block) { + return null; + } + blocks.push(block); + cursor = skipWhitespace(text, block.end); + } + return blocks.length > 0 ? blocks : null; +} + +export function stripPlainTextToolCallBlocks(text: string): string { + if (!text || !/\[[A-Za-z0-9_-]+\]/.test(text)) { + return text; + } + let result = ""; + let cursor = 0; + let index = 0; + while (index < text.length) { + const lineStart = index === 0 || text[index - 1] === "\n"; + if (!lineStart) { + index += 1; + continue; + } + const blockStart = skipHorizontalWhitespace(text, index); + const block = parsePlainTextToolCallBlockAt(text, blockStart); + if (!block) { + index += 1; + continue; + } + result += text.slice(cursor, index); + cursor = block.end; + index = block.end; + } + result += text.slice(cursor); + return result; +}