From db4a6ffb62b57371773a50e4b8cfb8f42f56aced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 22:00:34 -0400 Subject: [PATCH] chore(node-sdk): audit providers copilot droid augment cline Providers use plugins/common.ts, shell-command.ts, and managed-runtime-status; isNodeExecutable recognizes the running executable or a node-named binary. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- src/plugins/augment-contract.ts | 49 +------------ src/plugins/augment-installation.ts | 16 +--- src/plugins/augment-io.ts | 105 +++------------------------ src/plugins/cline-contract.ts | 28 +------ src/plugins/cline-installation.ts | 12 +-- src/plugins/cline-io.ts | 98 +------------------------ src/plugins/codex-contract.ts | 4 +- src/plugins/copilot-contract.ts | 106 ++++----------------------- src/plugins/copilot-installation.ts | 13 +--- src/plugins/copilot-io.ts | 95 ++---------------------- src/plugins/cursor-contract.ts | 8 +- src/plugins/droid-contract.ts | 109 ++++------------------------ src/plugins/droid-installation.ts | 19 ++--- src/plugins/droid-io.ts | 97 +------------------------ src/plugins/droid-policy.ts | 7 +- src/plugins/gemini-command.ts | 4 +- src/plugins/shell-command.ts | 11 ++- 17 files changed, 89 insertions(+), 692 deletions(-) diff --git a/src/plugins/augment-contract.ts b/src/plugins/augment-contract.ts index 0a2efe5..16ecb59 100644 --- a/src/plugins/augment-contract.ts +++ b/src/plugins/augment-contract.ts @@ -1,6 +1,8 @@ import os from 'node:os'; import path from 'node:path'; -import { parseStrictJsonObject } from './strict-json.js'; +import { sameAgentId, samePath } from './common.js'; +import { quotePosix, readPosixArgument, type ParsedArgument } from './shell-command.js'; +import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'augment'; export const GUARD_SCRIPT = 'guard.js'; @@ -20,8 +22,6 @@ const SESSION_EVENTS = new Set([ 'PromptSubmit', ]); -export type JsonObject = Record; - export interface AugmentHandler extends JsonObject { readonly type: 'command'; readonly command: string; @@ -58,23 +58,10 @@ export interface RuntimeContract { readonly auditWrapperPath: string; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function hasOwn(value: JsonObject, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - function quoteWindows(value: string): string { return `"${value.replaceAll('"', '\\"')}"`; } @@ -98,42 +85,12 @@ function readWindowsArgument(command: string, start: number): ParsedArgument | u return undefined; } -const POSIX_APOSTROPHE = `'"'"'`; - -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(POSIX_APOSTROPHE, index)) { - value += "'"; - index += POSIX_APOSTROPHE.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - function parseWrapperCommand(command: string): string | undefined { const readArgument = process.platform === 'win32' ? readWindowsArgument : readPosixArgument; const parsed = readArgument(command, 0); return parsed?.next === command.length && parsed.value ? parsed.value : undefined; } -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; -} - export function wrapperPaths(agentDirectory: string): { readonly guardPath: string; readonly auditPath: string; diff --git a/src/plugins/augment-installation.ts b/src/plugins/augment-installation.ts index 341bd94..fdfa240 100644 --- a/src/plugins/augment-installation.ts +++ b/src/plugins/augment-installation.ts @@ -22,18 +22,10 @@ const DISPLAY_NAME = 'Augment Code CLI'; const SETTINGS_DIRECTORY_LABEL = 'Auggie configuration directory'; const SETTINGS_LABEL = 'Auggie user settings'; -export type AugmentRuntimePaths = ManagedRuntimePaths; -export type PreparedAugmentInstallation = PreparedManagedInstallation; -export type { RenameFile }; - -export function augmentRuntimePaths(config: InstallConfig): AugmentRuntimePaths { - return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); -} - export async function preflightAugmentInstallation( config: InstallConfig, settingsPath: string, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], @@ -44,13 +36,13 @@ export async function preflightAugmentInstallation( export async function prepareAugmentInstallation( config: InstallConfig, rendered: RenderedAugmentDocument, -): Promise { +): Promise { if (!rendered.changed && rendered.document.raw === undefined) { throw new Error('Auggie hook installation did not produce a settings document'); } const settingsSource = rendered.next ?? rendered.document.raw; if (settingsSource === undefined) throw new Error('Auggie hook settings are missing'); - const paths = augmentRuntimePaths(config); + const paths = managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); return prepareManagedInstallation({ agentKey: AGENT_KEY, displayName: DISPLAY_NAME, @@ -78,7 +70,7 @@ export async function prepareAugmentInstallation( } export async function commitAugmentInstallation( - prepared: PreparedAugmentInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/augment-io.ts b/src/plugins/augment-io.ts index ead189f..bc81e4f 100644 --- a/src/plugins/augment-io.ts +++ b/src/plugins/augment-io.ts @@ -2,9 +2,7 @@ import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, - AUDIT_SCRIPT, AUDIT_WRAPPER, - GUARD_SCRIPT, GUARD_WRAPPER, buildWrapper, createAugmentDocument, @@ -13,15 +11,13 @@ import { type RenderedAugmentDocument, type RuntimeContract, } from './augment-contract.js'; +import { MAX_CONFIG_BYTES, samePath } from './common.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; import { commitManagedTransaction, prepareManagedFileChange, } from './managed-transaction.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; - -const MAX_SECRET_BYTES = 64 * 1024; -const MAX_CONFIG_BYTES = 512 * 1024; export function resolveConfigPath(): string { return path.join(os.homedir(), '.augment', 'settings.json'); @@ -59,102 +55,21 @@ export async function writeAugmentDocument(rendered: RenderedAugmentDocument): P }); } -function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function requireNonEmptyString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: RuntimeContract, - configPath: string, -): void { - const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); - const extra = Object.keys(config).find((key) => !supported.has(key)); - if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); - requireNonEmptyString(config.org_id, 'org_id', configPath); - requireNonEmptyString(config.kid, 'kid', configPath); - const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath); - if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Auggie hooks: ${configPath}`); - } - if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); - const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath); - let baseUrl: URL; - try { - baseUrl = new URL(rawBaseUrl); - } catch (error) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { - cause: error instanceof Error ? error : new Error(String(error)), - }); - } - if (!['http:', 'https:'].includes(baseUrl.protocol) - || !baseUrl.hostname - || baseUrl.username - || baseUrl.password - || baseUrl.search - || baseUrl.hash) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); - } -} - -function validatePrivateKey(contents: string, keyPath: string): void { - const bytes = Buffer.from(contents, 'base64url'); - if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { - throw new Error(`Elydora private key is invalid: ${keyPath}`); - } -} - -function validContractPaths(contract: RuntimeContract, agentDirectory: string): boolean { - return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) - && samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) - && samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)) - && samePath(contract.guardWrapperPath, path.join(agentDirectory, GUARD_WRAPPER)) +function wrappersValid(contract: RuntimeContract): boolean { + const agentDirectory = path.dirname(contract.guardPath); + return samePath(contract.guardWrapperPath, path.join(agentDirectory, GUARD_WRAPPER)) && samePath(contract.auditWrapperPath, path.join(agentDirectory, AUDIT_WRAPPER)); } async function runtimeContractExists(contract: RuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!validContractPaths(contract, agentDirectory)) return false; - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; - if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) { - return false; - } - const configPath = path.join(agentDirectory, 'config.json'); - const keyPath = path.join(agentDirectory, 'private.key'); - const [config, key, guard, audit, guardWrapper, auditWrapper] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), - readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + if (!wrappersValid(contract)) return false; + if (!await managedRuntimeFilesExist(contract, AGENT_KEY)) return false; + const [guardWrapper, auditWrapper] = await Promise.all([ readPhysicalFile(contract.guardWrapperPath, 'Auggie guard wrapper'), readPhysicalFile(contract.auditWrapperPath, 'Auggie audit wrapper'), ]); - if (!config || !key || !guard || !audit || !guardWrapper || !auditWrapper) return false; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - return guard.contents.length > 0 - && audit.contents.length > 0 - && guardWrapper.contents === buildWrapper(contract.guardPath) - && auditWrapper.contents === buildWrapper(contract.auditPath); + return guardWrapper?.contents === buildWrapper(contract.guardPath) + && auditWrapper?.contents === buildWrapper(contract.auditPath); } export async function augmentRuntimeFilesExist( diff --git a/src/plugins/cline-contract.ts b/src/plugins/cline-contract.ts index 7ee9829..8669f1c 100644 --- a/src/plugins/cline-contract.ts +++ b/src/plugins/cline-contract.ts @@ -1,5 +1,7 @@ import os from 'node:os'; import path from 'node:path'; +import { asError, errorMessage, sameAgentId, samePath } from './common.js'; +import { isObject } from './strict-json.js'; export const AGENT_KEY = 'cline'; export const GUARD_SCRIPT = 'guard.js'; @@ -33,32 +35,6 @@ export interface ClineRuntimeContract { readonly auditPath: string; } -type JsonObject = Record; - -function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; -} - export function resolveHooksDirectory(): string { const configuredDirectory = process.env.CLINE_DIR?.trim(); const clineDirectory = configuredDirectory diff --git a/src/plugins/cline-installation.ts b/src/plugins/cline-installation.ts index e3d2cce..0f8921a 100644 --- a/src/plugins/cline-installation.ts +++ b/src/plugins/cline-installation.ts @@ -9,8 +9,8 @@ import { buildWrapper, type ClineHookFile, runtimeContract, - sameAgentId, } from './cline-contract.js'; +import { sameAgentId } from './common.js'; import { commitManagedInstallation, preflightManagedInstallation, @@ -28,10 +28,6 @@ import { const DISPLAY_NAME = 'Cline'; const DIRECTORY_LABEL = 'Cline hooks directory'; -export type ClineRuntimePaths = ManagedRuntimePaths; -export type PreparedClineInstallation = PreparedManagedInstallation; -export type { RenameFile }; - function hookLocations(files: readonly ClineHookFile[]) { return files.map((file) => ({ directoryLabel: DIRECTORY_LABEL, @@ -42,7 +38,7 @@ function hookLocations(files: readonly ClineHookFile[]) { export async function preflightClineInstallation( config: InstallConfig, files: readonly ClineHookFile[], -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: hookLocations(files), @@ -54,7 +50,7 @@ export async function prepareClineInstallation( config: InstallConfig, guardFile: ClineHookFile, auditFile: ClineHookFile, -): Promise { +): Promise { const paths = await preflightClineInstallation(config, [guardFile, auditFile]); const guardMetadata = buildMetadata('guard', config.agentId, paths.guardPath); const auditMetadata = buildMetadata('audit', config.agentId, paths.auditPath); @@ -86,7 +82,7 @@ export async function prepareClineInstallation( } export async function commitClineInstallation( - prepared: PreparedClineInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/cline-io.ts b/src/plugins/cline-io.ts index 8ac5c83..4446cf4 100644 --- a/src/plugins/cline-io.ts +++ b/src/plugins/cline-io.ts @@ -1,21 +1,12 @@ -import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, - AUDIT_SCRIPT, - GUARD_SCRIPT, type ClineHookFile, type ClineRuntimeContract, parseMetadata, - sameAgentId, } from './cline-contract.js'; -import { generateGuardScript } from './guard-template.js'; -import { generateHookScript } from './hook-template.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; - -const MAX_SECRET_BYTES = 64 * 1024; -const MAX_CONFIG_BYTES = 512 * 1024; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; export async function readHookFile(filePath: string): Promise { const directory = path.dirname(filePath); @@ -38,91 +29,6 @@ export function requireAvailableHookFile(file: ClineHookFile): void { } } -function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -function requireString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: ClineRuntimeContract, - configPath: string, -): void { - const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); - const extra = Object.keys(config).find((key) => !supported.has(key)); - if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); - requireString(config.org_id, 'org_id', configPath); - requireString(config.kid, 'kid', configPath); - const agentId = requireString(config.agent_id, 'agent_id', configPath); - if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Cline hooks: ${configPath}`); - } - if (config.token !== undefined) requireString(config.token, 'token', configPath); - const rawBaseUrl = requireString(config.base_url, 'base_url', configPath); - let baseUrl: URL; - try { - baseUrl = new URL(rawBaseUrl); - } catch (error) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { - cause: error instanceof Error ? error : new Error(String(error)), - }); - } - if (!['http:', 'https:'].includes(baseUrl.protocol) - || !baseUrl.hostname - || baseUrl.username - || baseUrl.password - || baseUrl.search - || baseUrl.hash) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); - } -} - -function validatePrivateKey(contents: string, keyPath: string): void { - const bytes = Buffer.from(contents, 'base64url'); - if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { - throw new Error(`Elydora private key is invalid: ${keyPath}`); - } -} - -function validContractPaths(contract: ClineRuntimeContract): boolean { - return samePath(path.dirname(contract.agentDirectory), path.join(os.homedir(), '.elydora')) - && samePath(contract.guardPath, path.join(contract.agentDirectory, GUARD_SCRIPT)) - && samePath(contract.auditPath, path.join(contract.agentDirectory, AUDIT_SCRIPT)); -} - export async function runtimeFilesExist(contract: ClineRuntimeContract): Promise { - if (!validContractPaths(contract)) return false; - const runtimeRoot = path.join(os.homedir(), '.elydora'); - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; - if (!await inspectPhysicalDirectory(contract.agentDirectory, 'Elydora agent runtime directory')) { - return false; - } - const configPath = path.join(contract.agentDirectory, 'config.json'); - const keyPath = path.join(contract.agentDirectory, 'private.key'); - const [config, key, guard, audit] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), - readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), - ]); - if (!config || !key || !guard || !audit) return false; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) - && audit.contents === generateHookScript( - AGENT_KEY, - contract.agentId, - { nativePayload: true }, - ); + return managedRuntimeFilesExist(contract, AGENT_KEY); } diff --git a/src/plugins/codex-contract.ts b/src/plugins/codex-contract.ts index affb1dd..519c943 100644 --- a/src/plugins/codex-contract.ts +++ b/src/plugins/codex-contract.ts @@ -7,7 +7,7 @@ import { parseEncodedWindowsCommand, parseLegacyWindowsCommand, parsePosixCommand, - quotePosix, + posixSource, } from './shell-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; @@ -45,7 +45,7 @@ export interface RuntimeContract { export function buildHandler(scriptPath: string, statusMessage: string): JsonObject { return { type: 'command', - command: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, + command: posixSource(scriptPath), commandWindows: encodedWindowsCommand(scriptPath), timeout: HOOK_TIMEOUT_SECONDS, statusMessage, diff --git a/src/plugins/copilot-contract.ts b/src/plugins/copilot-contract.ts index 13d66d7..30923f9 100644 --- a/src/plugins/copilot-contract.ts +++ b/src/plugins/copilot-contract.ts @@ -1,6 +1,14 @@ import os from 'node:os'; import path from 'node:path'; +import { sameAgentId, samePath } from './common.js'; import type { FileSnapshot } from './managed-files.js'; +import { + isNodeExecutable, + parsePosixCommand, + parsePowerShellSource, + posixSource, + powerShellSource, +} from './shell-command.js'; import { validateHooks, type CopilotHooks } from './copilot-schema.js'; import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; @@ -51,122 +59,38 @@ export interface RuntimeContract { readonly auditPath: string; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - interface ManagedEntry { readonly agentId: string; readonly scriptPath: string; } -export function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - function agentKey(agentId: string): string { return process.platform === 'win32' ? agentId.toLowerCase() : agentId; } -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - export function buildHandler(scriptPath: string): JsonObject { return { type: 'command', - bash: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`, - powershell: `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`, + bash: posixSource(scriptPath), + powershell: powerShellSource(scriptPath), timeoutSec: HOOK_TIMEOUT_SECONDS, }; } -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - const apostrophe = `'"'"'`; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(apostrophe, index)) { - value += "'"; - index += apostrophe.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parseGeneratedBash(command: unknown): readonly [string, string] | undefined { - if (typeof command !== 'string') return undefined; - const executable = readPosixArgument(command, 0); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPosixArgument(command, executable.next + 1); - if (!script || script.next !== command.length) return undefined; - return [executable.value, script.value]; -} - -function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] !== "'") { - value += command[index]; - continue; - } - if (command[index + 1] === "'") { - value += "'"; - index += 1; - continue; - } - return { value, next: index + 1 }; - } - return undefined; -} - -function parseGeneratedPowerShell(command: unknown): readonly [string, string] | undefined { - if (typeof command !== 'string' || !command.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(command, 2); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(command, executable.next + 1); - if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - function exactHandlerKeys(handler: JsonObject): boolean { return Object.keys(handler).sort().join(',') === 'bash,powershell,timeoutSec,type'; } -function nodeExecutable(filePath: string): boolean { - if (!path.isAbsolute(filePath)) return false; - const name = path.basename(filePath).toLowerCase(); - return name === 'node' || name === 'node.exe'; -} - function currentManagedScriptPath(handler: JsonObject): string | undefined { if (!exactHandlerKeys(handler) || handler.type !== 'command' || handler.timeoutSec !== HOOK_TIMEOUT_SECONDS) return undefined; - const bash = parseGeneratedBash(handler.bash); - const powershell = parseGeneratedPowerShell(handler.powershell); + if (typeof handler.bash !== 'string' || typeof handler.powershell !== 'string') return undefined; + const bash = parsePosixCommand(handler.bash); + const powershell = parsePowerShellSource(handler.powershell); if (!bash || !powershell - || !nodeExecutable(bash[0]) + || !path.isAbsolute(bash[0]) + || !isNodeExecutable(bash[0]) || !samePath(bash[0], powershell[0]) || !samePath(bash[1], powershell[1])) return undefined; return bash[1]; diff --git a/src/plugins/copilot-installation.ts b/src/plugins/copilot-installation.ts index cba7995..9b6a223 100644 --- a/src/plugins/copilot-installation.ts +++ b/src/plugins/copilot-installation.ts @@ -6,8 +6,8 @@ import { GUARD_SCRIPT, type CopilotSources, type RenderedDocument, - samePath, } from './copilot-contract.js'; +import { MAX_SOURCE_BYTES, samePath } from './common.js'; import { requireHooksEnabled } from './copilot-io.js'; import { commitManagedInstallation, @@ -26,11 +26,6 @@ import { const DISPLAY_NAME = 'GitHub Copilot CLI'; const DIRECTORY_LABEL = 'GitHub Copilot hooks directory'; const SOURCE_LABEL = 'GitHub Copilot hook source'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - -export type CopilotRuntimePaths = ManagedRuntimePaths; -export type PreparedCopilotInstallation = PreparedManagedInstallation; -export type { RenameFile }; function hookLocations(sources: CopilotSources) { return [sources.user, ...(sources.legacy ? [sources.legacy] : [])].map((document) => ({ @@ -42,7 +37,7 @@ function hookLocations(sources: CopilotSources) { export async function preflightCopilotInstallation( config: InstallConfig, sources: CopilotSources, -): Promise { +): Promise { requireHooksEnabled(sources); return preflightManagedInstallation({ agentKey: AGENT_KEY, @@ -63,7 +58,7 @@ export async function prepareCopilotInstallation( config: InstallConfig, sources: CopilotSources, rendered: readonly RenderedDocument[], -): Promise { +): Promise { await preflightCopilotInstallation(config, sources); const documents = installationDocuments(sources, rendered); const prepared = await prepareManagedInstallation({ @@ -107,7 +102,7 @@ export async function prepareCopilotInstallation( } export async function commitCopilotInstallation( - prepared: PreparedCopilotInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/copilot-io.ts b/src/plugins/copilot-io.ts index 5df7262..115edd6 100644 --- a/src/plugins/copilot-io.ts +++ b/src/plugins/copilot-io.ts @@ -2,34 +2,26 @@ import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, - AUDIT_SCRIPT, CONFIG_FILE, - GUARD_SCRIPT, type CopilotDocument, type CopilotSources, type RuntimeContract, createDocument, parseDocument, - sameAgentId, - samePath, } from './copilot-contract.js'; -import { generateGuardScript } from './guard-template.js'; -import { generateHookScript } from './hook-template.js'; +import { MAX_SOURCE_BYTES } from './common.js'; import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot, } from './managed-files.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; import { parseStrictJsonObject, parseStrictJsoncObject, type JsonObject, } from './strict-json.js'; -const MAX_SECRET_BYTES = 64 * 1024; -const MAX_CONFIG_BYTES = 512 * 1024; -const MAX_SETTINGS_BYTES = 2 * 1024 * 1024; - export interface CopilotPaths { readonly copilotHome: string; readonly userHooksDirectory: string; @@ -98,7 +90,7 @@ async function readHookDocument( filePath: string, label: string, ): Promise { - const snapshot = await readPhysicalFile(filePath, label, MAX_SETTINGS_BYTES); + const snapshot = await readPhysicalFile(filePath, label, MAX_SOURCE_BYTES); return snapshot ? parseDocument(filePath, snapshot, label) : undefined; } @@ -111,7 +103,7 @@ function parseSettings(raw: string, layer: SettingsLayer): JsonObject { } async function readSettingsLayer(layer: SettingsLayer): Promise { - const snapshot = await readPhysicalFile(layer.filePath, layer.label, MAX_SETTINGS_BYTES); + const snapshot = await readPhysicalFile(layer.filePath, layer.label, MAX_SOURCE_BYTES); if (!snapshot) return layer; const root = parseSettings(snapshot.contents, layer); if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { @@ -167,86 +159,9 @@ export function requireHooksEnabled(sources: CopilotSources): void { } } -function requireString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: RuntimeContract, - configPath: string, -): void { - const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); - const extra = Object.keys(config).find((key) => !supported.has(key)); - if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); - requireString(config.org_id, 'org_id', configPath); - requireString(config.kid, 'kid', configPath); - const agentId = requireString(config.agent_id, 'agent_id', configPath); - if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Copilot hooks: ${configPath}`); - } - if (config.token !== undefined) requireString(config.token, 'token', configPath); - const rawBaseUrl = requireString(config.base_url, 'base_url', configPath); - let baseUrl: URL; - try { - baseUrl = new URL(rawBaseUrl); - } catch (error) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { - cause: error instanceof Error ? error : new Error(String(error)), - }); - } - if (!['http:', 'https:'].includes(baseUrl.protocol) - || !baseUrl.hostname - || baseUrl.username - || baseUrl.password - || baseUrl.search - || baseUrl.hash) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); - } -} - -function validatePrivateKey(contents: string, keyPath: string): void { - const bytes = Buffer.from(contents, 'base64url'); - if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { - throw new Error(`Elydora private key is invalid: ${keyPath}`); - } -} - -function validContractPaths(contract: RuntimeContract): boolean { - const agentDirectory = path.dirname(contract.guardPath); - return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) - && samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) - && samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)); -} - export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { for (const contract of contracts) { - if (!validContractPaths(contract)) continue; - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) continue; - if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) continue; - const configPath = path.join(agentDirectory, 'config.json'); - const keyPath = path.join(agentDirectory, 'private.key'); - const [config, key, guard, audit] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), - readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), - ]); - if (!config || !key || !guard || !audit) continue; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - if (guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) - && audit.contents === generateHookScript( - AGENT_KEY, - contract.agentId, - { nativePayload: true }, - )) return true; + if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/cursor-contract.ts b/src/plugins/cursor-contract.ts index 1d7050b..527a1ac 100644 --- a/src/plugins/cursor-contract.ts +++ b/src/plugins/cursor-contract.ts @@ -7,8 +7,8 @@ import { isNodeExecutable, parsePosixCommand, parsePowerShellSource, - quotePosix, - quotePowerShell, + posixSource, + powerShellSource, } from './shell-command.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; @@ -52,8 +52,8 @@ export interface RuntimeContract { export function buildHandler(scriptPath: string): JsonObject { const command = process.platform === 'win32' - ? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE` - : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; + ? powerShellSource(scriptPath) + : posixSource(scriptPath); return { command, timeout: HOOK_TIMEOUT_SECONDS, diff --git a/src/plugins/droid-contract.ts b/src/plugins/droid-contract.ts index 641f8b0..416a0c1 100644 --- a/src/plugins/droid-contract.ts +++ b/src/plugins/droid-contract.ts @@ -1,5 +1,13 @@ import os from 'node:os'; import path from 'node:path'; +import { sameAgentId, samePath } from './common.js'; +import { + parseLegacyWindowsCommand, + parsePosixCommand, + parsePowerShellSource, + posixSource, + powerShellSource, +} from './shell-command.js'; import type { JsonObject } from './strict-json.js'; export const AGENT_KEY = 'droid'; @@ -10,7 +18,6 @@ export const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'] as const; const HANDLER_KEYS = ['command', 'timeout', 'type']; const GROUP_KEYS = ['hooks', 'matcher']; -const WINDOWS_EXIT_SUFFIX = '; exit $LASTEXITCODE'; export type ToolEvent = typeof TOOL_EVENTS[number]; @@ -44,26 +51,13 @@ export interface ManagedRemoval { readonly removeGroup: boolean; } -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - export function buildCommand(scriptPath: string): string { if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { throw new Error('Factory Droid hook commands require absolute executable and script paths'); } return process.platform === 'win32' - ? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${WINDOWS_EXIT_SUFFIX}` - : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; + ? powerShellSource(scriptPath) + : posixSource(scriptPath); } export function buildGroup(scriptPath: string): DroidGroup { @@ -150,90 +144,13 @@ export function readHookMap(value: unknown, label: string): DroidHookMap { return hooks; } -function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length; index += 1) { - if (command[index] !== "'") { - value += command[index]; - continue; - } - if (command[index + 1] === "'") { - value += "'"; - index += 1; - continue; - } - return { value, next: index + 1 }; - } - return undefined; -} - -const POSIX_APOSTROPHE = `'"'"'`; - -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { - if (command[start] !== "'") return undefined; - let value = ''; - for (let index = start + 1; index < command.length;) { - if (command.startsWith(POSIX_APOSTROPHE, index)) { - value += "'"; - index += POSIX_APOSTROPHE.length; - continue; - } - if (command[index] === "'") return { value, next: index + 1 }; - value += command[index]; - index += 1; - } - return undefined; -} - -function parseTwoArguments( - command: string, - start: number, - readArgument: (command: string, start: number) => ParsedArgument | undefined, -): readonly [string, string] | undefined { - const executable = readArgument(command, start); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readArgument(command, executable.next + 1); - if (!script || script.next !== command.length || !executable.value || !script.value) return undefined; - return [executable.value, script.value]; -} - -function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined { - const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); - return match ? [match[1], match[2]] : undefined; -} - -function parsePowerShellCommand(command: string): readonly [string, string] | undefined { - if (!command.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(command, 2); - if (!executable || command[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(command, executable.next + 1); - if (!script - || command.slice(script.next) !== WINDOWS_EXIT_SUFFIX - || !executable.value - || !script.value) return undefined; - return [executable.value, script.value]; -} - function parseGeneratedCommand( command: string, includeLegacy: boolean, ): readonly [string, string] | undefined { - if (process.platform !== 'win32') return parseTwoArguments(command, 0, readPosixArgument); - const current = parsePowerShellCommand(command); - return current ?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined); -} - -export function samePath(left: string, right: string): boolean { - const normalizedLeft = path.resolve(left); - const normalizedRight = path.resolve(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; + if (process.platform !== 'win32') return parsePosixCommand(command); + return parsePowerShellSource(command) + ?? (includeLegacy ? parseLegacyWindowsCommand(command) : undefined); } export function managedAgentId( diff --git a/src/plugins/droid-installation.ts b/src/plugins/droid-installation.ts index 39f77d0..4b66005 100644 --- a/src/plugins/droid-installation.ts +++ b/src/plugins/droid-installation.ts @@ -1,11 +1,7 @@ import path from 'node:path'; import type { InstallConfig } from './base.js'; -import { - AGENT_KEY, - AUDIT_SCRIPT, - GUARD_SCRIPT, - samePath, -} from './droid-contract.js'; +import { MAX_SOURCE_BYTES, samePath } from './common.js'; +import { AGENT_KEY, AUDIT_SCRIPT, GUARD_SCRIPT } from './droid-contract.js'; import { activeDocument, installationDocuments, @@ -31,11 +27,6 @@ import { const DISPLAY_NAME = 'Factory Droid'; const DIRECTORY_LABEL = 'Factory Droid user configuration directory'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - -export type DroidRuntimePaths = ManagedRuntimePaths; -export type PreparedDroidInstallation = PreparedManagedInstallation; -export type { RenameFile }; function sourceLabel(document: DroidDocument): string { if (document.kind === 'settings') return 'Factory Droid user settings'; @@ -56,7 +47,7 @@ function hookLocations(sources: DroidSources) { export async function preflightDroidInstallation( config: InstallConfig, sources: DroidSources, -): Promise { +): Promise { requireHooksEnabled(sources); return preflightManagedInstallation({ agentKey: AGENT_KEY, @@ -92,7 +83,7 @@ export async function prepareDroidInstallation( config: InstallConfig, sources: DroidSources, rendered: readonly RenderedDocument[], -): Promise { +): Promise { await preflightDroidInstallation(config, sources); const changed = installationChanges(sources, rendered); const prepared = await prepareManagedInstallation({ @@ -124,7 +115,7 @@ export async function prepareDroidInstallation( } export async function commitDroidInstallation( - prepared: PreparedDroidInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/droid-io.ts b/src/plugins/droid-io.ts index ffeec04..d57bae6 100644 --- a/src/plugins/droid-io.ts +++ b/src/plugins/droid-io.ts @@ -1,13 +1,7 @@ import os from 'node:os'; import path from 'node:path'; -import { - AGENT_KEY, - AUDIT_SCRIPT, - GUARD_SCRIPT, - type RuntimeContract, - sameAgentId, - samePath, -} from './droid-contract.js'; +import { MAX_SOURCE_BYTES } from './common.js'; +import { AGENT_KEY, type RuntimeContract } from './droid-contract.js'; import { activeDocument, createLegacyHookDocument, @@ -19,15 +13,9 @@ import { type DroidDocumentKind, type DroidSources, } from './droid-config.js'; -import { generateGuardScript } from './guard-template.js'; -import { generateHookScript } from './hook-template.js'; import { readDroidPolicy } from './droid-policy.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; - -const MAX_SECRET_BYTES = 64 * 1024; -const MAX_CONFIG_BYTES = 512 * 1024; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; interface FactoryPaths { readonly directory: string; @@ -96,86 +84,9 @@ export function requireHooksEnabled(sources: DroidSources): void { } } -function requireString(value: unknown, field: string, configPath: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); - } - return value; -} - -function validateRuntimeConfig( - config: JsonObject, - contract: RuntimeContract, - configPath: string, -): void { - const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); - const extra = Object.keys(config).find((key) => !supported.has(key)); - if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); - requireString(config.org_id, 'org_id', configPath); - requireString(config.kid, 'kid', configPath); - const agentId = requireString(config.agent_id, 'agent_id', configPath); - if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Factory Droid hooks: ${configPath}`); - } - if (config.token !== undefined) requireString(config.token, 'token', configPath); - const rawBaseUrl = requireString(config.base_url, 'base_url', configPath); - let baseUrl: URL; - try { - baseUrl = new URL(rawBaseUrl); - } catch (error) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { - cause: error instanceof Error ? error : new Error(String(error)), - }); - } - if (!['http:', 'https:'].includes(baseUrl.protocol) - || !baseUrl.hostname - || baseUrl.username - || baseUrl.password - || baseUrl.search - || baseUrl.hash) { - throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); - } -} - -function validatePrivateKey(contents: string, keyPath: string): void { - const bytes = Buffer.from(contents, 'base64url'); - if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { - throw new Error(`Elydora private key is invalid: ${keyPath}`); - } -} - -function validContractPaths(contract: RuntimeContract): boolean { - const agentDirectory = path.dirname(contract.guardPath); - return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) - && samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) - && samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)); -} - export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { for (const contract of contracts) { - if (!validContractPaths(contract)) continue; - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) continue; - if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) continue; - const configPath = path.join(agentDirectory, 'config.json'); - const keyPath = path.join(agentDirectory, 'private.key'); - const [config, key, guard, audit] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), - readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), - readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), - readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), - ]); - if (!config || !key || !guard || !audit) continue; - const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); - validateRuntimeConfig(parsed, contract, configPath); - validatePrivateKey(key.contents, keyPath); - if (guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) - && audit.contents === generateHookScript( - AGENT_KEY, - contract.agentId, - { nativePayload: true }, - )) return true; + if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/droid-policy.ts b/src/plugins/droid-policy.ts index 1790343..63e35d9 100644 --- a/src/plugins/droid-policy.ts +++ b/src/plugins/droid-policy.ts @@ -1,11 +1,10 @@ import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { hasCode, MAX_SOURCE_BYTES } from './common.js'; import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot } from './managed-files.js'; import { parseStrictJsoncObject, type JsonObject } from './strict-json.js'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - interface PolicyLocation { readonly filePath: string; readonly label: string; @@ -37,10 +36,6 @@ export interface DroidPolicyState { readonly preconditions: readonly DroidPolicyPrecondition[]; } -function hasCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code; -} - function optionalBoolean(root: JsonObject, field: string, label: string): boolean | undefined { const value = root[field]; if (value !== undefined && typeof value !== 'boolean') { diff --git a/src/plugins/gemini-command.ts b/src/plugins/gemini-command.ts index c5e0d8f..293cad3 100644 --- a/src/plugins/gemini-command.ts +++ b/src/plugins/gemini-command.ts @@ -8,7 +8,7 @@ import { isPowerShellExecutable, parsePosixCommand, parsePowerShellSource, - quotePosix, + posixSource, quotePowerShell, windowsPowerShellPath, } from './shell-command.js'; @@ -28,7 +28,7 @@ export function buildGeminiCommand(scriptPath: string): string { } return process.platform === 'win32' ? windowsCommand(scriptPath) - : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; + : posixSource(scriptPath); } function parseWindowsCommand(command: string): readonly [string, string] | undefined { diff --git a/src/plugins/shell-command.ts b/src/plugins/shell-command.ts index 55baa78..a9ae683 100644 --- a/src/plugins/shell-command.ts +++ b/src/plugins/shell-command.ts @@ -1,6 +1,7 @@ import path from 'node:path'; +import { samePath } from './common.js'; -interface ParsedArgument { +export interface ParsedArgument { readonly value: string; readonly next: number; } @@ -29,6 +30,10 @@ export function windowsPowerShellPath(): string { return path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); } +export function posixSource(scriptPath: string): string { + return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; +} + export function powerShellSource(scriptPath: string): string { return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}${POWERSHELL_EXIT_SUFFIX}`; } @@ -41,7 +46,9 @@ export function encodedWindowsCommand(scriptPath: string): string { return `"${windowsPowerShellPath()}" ${ENCODED_COMMAND_FLAGS} ${encodePowerShellSource(scriptPath)}`; } +// The running executable, or a binary named node (node.exe case-insensitive on Windows). export function isNodeExecutable(filePath: string): boolean { + if (samePath(filePath, process.execPath)) return true; const basename = path.basename(filePath); return basename === 'node' || basename.toLowerCase() === 'node.exe'; } @@ -51,7 +58,7 @@ export function isPowerShellExecutable(filePath: string): boolean { && path.win32.basename(filePath).toLowerCase() === 'powershell.exe'; } -function readPosixArgument(command: string, start: number): ParsedArgument | undefined { +export function readPosixArgument(command: string, start: number): ParsedArgument | undefined { if (command[start] !== "'") return undefined; let value = ''; for (let index = start + 1; index < command.length;) {