From 3b5cbd6e171b2741123a6d055b6af588fe886370 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:07:30 -0400 Subject: [PATCH] chore(node-sdk): audit providers kimi grok qwen letta Command files use shell-command and managedScriptReference; runtime checks use managed-runtime-status; jsonc-edit.ts and parseCommentedJsonObject replace copied JSONC helpers. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- src/plugins/common.ts | 29 +++++- src/plugins/droid-config.ts | 35 ++----- src/plugins/gemini-config.ts | 95 ++---------------- src/plugins/grok-command.ts | 157 +++--------------------------- src/plugins/grok-contract.ts | 8 +- src/plugins/grok-installation.ts | 16 ++- src/plugins/grok-io.ts | 82 ++-------------- src/plugins/jsonc-edit.ts | 23 +++++ src/plugins/kimi-command.ts | 157 +++--------------------------- src/plugins/kimi-contract.ts | 17 +--- src/plugins/kimi-installation.ts | 10 +- src/plugins/kimi-io.ts | 92 +---------------- src/plugins/letta-command.ts | 126 +++--------------------- src/plugins/letta-config.ts | 42 ++------ src/plugins/letta-contract.ts | 9 +- src/plugins/letta-installation.ts | 15 +-- src/plugins/letta-sources.ts | 18 +--- src/plugins/qwen-command.ts | 119 +++------------------- src/plugins/qwen-config.ts | 96 ++---------------- src/plugins/qwen-contract.ts | 9 +- src/plugins/qwen-installation.ts | 15 +-- src/plugins/qwen-io.ts | 94 +----------------- src/plugins/qwen-sources.ts | 22 ++--- src/plugins/strict-json.ts | 7 ++ 24 files changed, 209 insertions(+), 1084 deletions(-) create mode 100644 src/plugins/jsonc-edit.ts diff --git a/src/plugins/common.ts b/src/plugins/common.ts index b168bbb..2468ca7 100644 --- a/src/plugins/common.ts +++ b/src/plugins/common.ts @@ -1,3 +1,4 @@ +import os from 'node:os'; import path from 'node:path'; export const MAX_SECRET_BYTES = 64 * 1024; @@ -16,12 +17,13 @@ export function hasCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code; } +export function pathKey(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + 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; + return pathKey(left) === pathKey(right); } export function sameAgentId(left: unknown, right: string): boolean { @@ -29,3 +31,20 @@ export function sameAgentId(left: unknown, right: string): boolean { ? left.toLowerCase() === right.toLowerCase() : left === right); } + +export interface ManagedScriptReference { + readonly agentId: string; + readonly scriptPath: string; +} + +// Accepts only ~/.elydora//. +export function managedScriptReference( + scriptPath: string, + scriptName: string, +): ManagedScriptReference | undefined { + if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined; + const agentDirectory = path.dirname(scriptPath); + if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) return undefined; + const agentId = path.basename(agentDirectory); + return agentId && agentId !== '.' && agentId !== '..' ? { agentId, scriptPath } : undefined; +} diff --git a/src/plugins/droid-config.ts b/src/plugins/droid-config.ts index f5f9e19..1a4d0dd 100644 --- a/src/plugins/droid-config.ts +++ b/src/plugins/droid-config.ts @@ -1,9 +1,5 @@ -import { - applyEdits, - modify, - type FormattingOptions, - type JSONPath, -} from 'jsonc-parser'; +import type { JSONPath } from 'jsonc-parser'; +import { changeJsonc } from './jsonc-edit.js'; import type { FileSnapshot } from './managed-files.js'; import type { DroidPolicyState } from './droid-policy.js'; import { @@ -158,23 +154,6 @@ function eventPath(document: DroidDocument, event: string): JSONPath { return [...document.basePath, event]; } -function formatting(raw: string): FormattingOptions { - const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; - const insertSpaces = !indentation?.includes('\t'); - return { - eol: raw.includes('\r\n') ? '\r\n' : '\n', - insertSpaces, - tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, - }; -} - -function change(raw: string, path: JSONPath, value: unknown, isArrayInsertion = false): string { - return applyEdits(raw, modify(raw, path, value, { - formattingOptions: formatting(raw), - isArrayInsertion, - })); -} - function currentDocument(document: DroidDocument, raw: string): DroidDocument { return parseDocument({ exists: document.exists, @@ -194,17 +173,17 @@ function removeManagedEntries(document: DroidDocument, raw: string, agentId?: st for (const removal of eventRemovals) { const groupPath = [...eventPath(document, event), removal.groupIndex]; if (removal.removeGroup) { - raw = change(raw, groupPath, undefined); + raw = changeJsonc(raw, groupPath, undefined); continue; } for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) { - raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined); } } if (eventRemovals.length > 0) { const current = currentDocument(document, raw); if ((current.hooks[event] ?? []).length === 0) { - raw = change(raw, eventPath(document, event), undefined); + raw = changeJsonc(raw, eventPath(document, event), undefined); } } } @@ -220,9 +199,9 @@ function appendGroup( const current = currentDocument(document, raw); if (Object.prototype.hasOwnProperty.call(current.hooks, event)) { const groups = current.hooks[event] ?? []; - return change(raw, [...eventPath(document, event), groups.length], group, true); + return changeJsonc(raw, [...eventPath(document, event), groups.length], group, true); } - return change(raw, eventPath(document, event), [group]); + return changeJsonc(raw, eventPath(document, event), [group]); } function hookFileIsEmpty(document: DroidDocument, raw: string): boolean { diff --git a/src/plugins/gemini-config.ts b/src/plugins/gemini-config.ts index c912efb..63af766 100644 --- a/src/plugins/gemini-config.ts +++ b/src/plugins/gemini-config.ts @@ -1,14 +1,4 @@ -import { - applyEdits, - modify, - parse, - parseTree, - printParseErrorCode, - type FormattingOptions, - type JSONPath, - type Node, - type ParseError, -} from 'jsonc-parser'; +import type { JSONPath } from 'jsonc-parser'; import { MANAGED_EVENTS, managedGeminiRemovals, @@ -19,7 +9,8 @@ import { type GeminiHooks, type ManagedGeminiEvent, } from './gemini-contract.js'; -import { isObject, type JsonObject } from './strict-json.js'; +import { changeJsonc } from './jsonc-edit.js'; +import { parseCommentedJsonObject, type JsonObject } from './strict-json.js'; export const GEMINI_OWNED_FILE_MARKER = '// Managed by Elydora'; @@ -46,53 +37,9 @@ interface DocumentOptions { readonly raw: string; } -function rejectDuplicateKeys( - node: Node | undefined, - label: string, - location: string[] = [], -): void { - if (!node) return; - if (node.type === 'object') { - const keys = new Set(); - for (const property of node.children ?? []) { - const key = String(property.children?.[0]?.value); - const childLocation = [...location, key]; - if (keys.has(key)) { - throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); - } - keys.add(key); - rejectDuplicateKeys(property.children?.[1], label, childLocation); - } - return; - } - if (node.type === 'array') { - for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); - } -} - -function parseJsoncObject(raw: string, label: string): JsonObject { - const errors: ParseError[] = []; - const value: unknown = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: false, - }); - if (errors.length > 0) { - const details = errors - .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) - .join(', '); - throw new Error(`Failed to parse ${label}: ${details}`); - } - if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - rejectDuplicateKeys(parseTree(raw, [], { - allowTrailingComma: false, - disallowComments: false, - }), label); - return value; -} - export function parseGeminiDocument(options: DocumentOptions): GeminiDocument { const label = `Gemini CLI user settings at ${options.filePath}`; - const root = parseJsoncObject(options.raw, label); + const root = parseCommentedJsonObject(options.raw, label); const hasHooksContainer = Object.hasOwn(root, 'hooks'); return { ...options, @@ -112,28 +59,6 @@ export function createGeminiDocument(filePath: string): GeminiDocument { }); } -function formatting(raw: string): FormattingOptions { - const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; - const insertSpaces = !indentation?.includes('\t'); - return { - eol: raw.includes('\r\n') ? '\r\n' : '\n', - insertSpaces, - tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, - }; -} - -function change( - raw: string, - jsonPath: JSONPath, - value: unknown, - isArrayInsertion = false, -): string { - return applyEdits(raw, modify(raw, jsonPath, value, { - formattingOptions: formatting(raw), - isArrayInsertion, - })); -} - function currentDocument(document: GeminiDocument, raw: string): GeminiDocument { return parseGeminiDocument({ exists: document.exists, @@ -155,25 +80,25 @@ function removeManagedEntries( for (const removal of eventRemovals) { const groupPath: JSONPath = ['hooks', event, removal.groupIndex]; if (removal.removeGroup) { - raw = change(raw, groupPath, undefined); + raw = changeJsonc(raw, groupPath, undefined); continue; } for (const handlerIndex of [...removal.handlerIndexes].sort( (left, right) => right - left, )) { - raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined); } } if (eventRemovals.length > 0) { const current = currentDocument(document, raw); if ((current.hooks[event] ?? []).length === 0) { - raw = change(raw, ['hooks', event], undefined); + raw = changeJsonc(raw, ['hooks', event], undefined); } } } const current = currentDocument(document, raw); if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { - raw = change(raw, ['hooks'], undefined); + raw = changeJsonc(raw, ['hooks'], undefined); } return raw; } @@ -186,9 +111,9 @@ function appendGroup( ): string { const current = currentDocument(document, raw); if (current.hooks[event]) { - return change(raw, ['hooks', event, current.hooks[event].length], group, true); + return changeJsonc(raw, ['hooks', event, current.hooks[event].length], group, true); } - return change(raw, ['hooks', event], [group]); + return changeJsonc(raw, ['hooks', event], [group]); } export function renderGeminiDocument( diff --git a/src/plugins/grok-command.ts b/src/plugins/grok-command.ts index 064121d..4611773 100644 --- a/src/plugins/grok-command.ts +++ b/src/plugins/grok-command.ts @@ -1,140 +1,21 @@ -import os from 'node:os'; import path from 'node:path'; +import { managedScriptReference, type ManagedScriptReference } from './common.js'; +import { + encodedWindowsCommand, + isNodeExecutable, + parseEncodedWindowsCommand, + parseLegacyWindowsCommand, + parsePosixCommand, + posixSource, +} from './shell-command.js'; -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export interface GrokRuntimeReference { - readonly agentId: string; - readonly scriptPath: string; -} - -export function sameGrokPath(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 sameGrokAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function windowsPowerShellPath(): string { - const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; - const systemRoot = configuredRoot - && path.win32.isAbsolute(configuredRoot) - && !/["%\r\n]/.test(configuredRoot) - ? configuredRoot - : 'C:\\Windows'; - return path.win32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - -function windowsCommand(scriptPath: string): string { - const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - const encoded = Buffer.from(source, 'utf16le').toString('base64'); - return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; -} +export type GrokRuntimeReference = ManagedScriptReference; export function buildGrokCommand(scriptPath: string): string { if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { throw new Error('Grok hook commands require absolute executable and script paths'); } - return process.platform === 'win32' - ? windowsCommand(scriptPath) - : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; -} - -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 parsePosixCommand(command: string): readonly [string, string] | 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 parsePowerShellSource(source: string): readonly [string, string] | undefined { - if (!source.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(source, 2); - if (!executable || source[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(source, executable.next + 1); - if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - -function parseWindowsCommand(command: string): readonly [string, string] | undefined { - const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); - if (!match - || !path.win32.isAbsolute(match[1]) - || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; - const encoded = match[2]; - const buffer = Buffer.from(encoded, 'base64'); - if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined; - return parsePowerShellSource(buffer.toString('utf16le')); -} - -function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined { - const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); - return match ? [match[1], match[2]] : undefined; -} - -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath).toLowerCase(); - return basename === 'node' || basename === 'node.exe'; + return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath); } export function grokRuntimeReference( @@ -142,18 +23,8 @@ export function grokRuntimeReference( scriptName: string, ): GrokRuntimeReference | undefined { const parsed = parsePosixCommand(command) - ?? parseWindowsCommand(command) + ?? parseEncodedWindowsCommand(command) ?? parseLegacyWindowsCommand(command); - if (!parsed - || !path.isAbsolute(parsed[0]) - || !path.isAbsolute(parsed[1]) - || !isNodeExecutable(parsed[0]) - || path.basename(parsed[1]) !== scriptName) return undefined; - const agentDirectory = path.dirname(parsed[1]); - if (!sameGrokPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { - return undefined; - } - const agentId = path.basename(agentDirectory); - if (!agentId || agentId === '.' || agentId === '..') return undefined; - return { agentId, scriptPath: parsed[1] }; + if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined; + return managedScriptReference(parsed[1], scriptName); } diff --git a/src/plugins/grok-contract.ts b/src/plugins/grok-contract.ts index b0dec80..5167d13 100644 --- a/src/plugins/grok-contract.ts +++ b/src/plugins/grok-contract.ts @@ -1,15 +1,19 @@ +import { sameAgentId } from './common.js'; import { buildGrokCommand, grokRuntimeReference, - sameGrokAgentId, type GrokRuntimeReference, } from './grok-command.js'; +import type { GuardScriptOptions } from './guard-template.js'; +import type { HookScriptOptions } from './hook-template.js'; import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'grok'; export const GUARD_SCRIPT = 'guard.js'; export const AUDIT_SCRIPT = 'hook.js'; export const HOOK_TIMEOUT_SECONDS = 10; +export const GUARD_OPTIONS: GuardScriptOptions = { denyProtocol: 'grok' }; +export const AUDIT_OPTIONS: HookScriptOptions = { nativePayload: true }; const MATCHER_REJECTING_EVENTS = new Set([ 'SessionStart', @@ -172,7 +176,7 @@ function removeFromGroups( const handlers = group.hooks.filter((handler) => { const reference = managedReference(handler, scriptName); return !reference - || (agentId !== undefined && !sameGrokAgentId(reference.agentId, agentId)); + || (agentId !== undefined && !sameAgentId(reference.agentId, agentId)); }); if (handlers.length > 0) result.push({ hooks: handlers }); } diff --git a/src/plugins/grok-installation.ts b/src/plugins/grok-installation.ts index e856f7b..c481dc2 100644 --- a/src/plugins/grok-installation.ts +++ b/src/plugins/grok-installation.ts @@ -2,7 +2,9 @@ import path from 'node:path'; import type { InstallConfig } from './base.js'; import { AGENT_KEY, + AUDIT_OPTIONS, AUDIT_SCRIPT, + GUARD_OPTIONS, GUARD_SCRIPT, type RenderedGrokDocument, } from './grok-contract.js'; @@ -24,14 +26,10 @@ const DISPLAY_NAME = 'Grok Build'; const HOOKS_DIRECTORY_LABEL = 'Grok hooks directory'; const HOOKS_LABEL = 'Grok user hooks'; -export type GrokRuntimePaths = ManagedRuntimePaths; -export type PreparedGrokInstallation = PreparedManagedInstallation; -export type { RenameFile }; - export async function preflightGrokInstallation( config: InstallConfig, hooksPath: string, -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }], @@ -42,7 +40,7 @@ export async function preflightGrokInstallation( export async function prepareGrokInstallation( config: InstallConfig, rendered: RenderedGrokDocument, -): Promise { +): Promise { const hooksSource = rendered.next ?? rendered.document.raw; if (hooksSource === undefined) throw new Error('Grok hook configuration is missing'); const prepared = await prepareManagedInstallation({ @@ -56,8 +54,8 @@ export async function prepareGrokInstallation( source: hooksSource, }], config, - guardOptions: { denyProtocol: 'grok' }, - auditOptions: { nativePayload: true }, + guardOptions: GUARD_OPTIONS, + auditOptions: AUDIT_OPTIONS, }, GUARD_SCRIPT, AUDIT_SCRIPT); const hooksDirectory = path.dirname(rendered.document.filePath); return { @@ -73,7 +71,7 @@ export async function prepareGrokInstallation( } export async function commitGrokInstallation( - prepared: PreparedGrokInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/grok-io.ts b/src/plugins/grok-io.ts index acdd999..55e2c9d 100644 --- a/src/plugins/grok-io.ts +++ b/src/plugins/grok-io.ts @@ -2,14 +2,15 @@ import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, + AUDIT_OPTIONS, + GUARD_OPTIONS, createGrokDocument, parseGrokDocument, type GrokDocument, type GrokRuntimeContract, } from './grok-contract.js'; -import { sameGrokAgentId, sameGrokPath } from './grok-command.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; const CONFIG_FILE = 'elydora-audit.json'; @@ -36,82 +37,15 @@ export async function readGrokDocument(): Promise { : createGrokDocument(filePath); } -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: GrokRuntimeContract, - 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 (!sameGrokAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Grok 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}`); - } -} - -async function runtimeContractExists(contract: GrokRuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!sameGrokPath(path.dirname(agentDirectory), runtimeRoot) - || !sameGrokPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) 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] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config'), - readPhysicalFile(keyPath, 'Elydora private key'), - 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.length > 0 && audit.contents.length > 0; -} - export async function grokRuntimeFilesExist( contracts: readonly GrokRuntimeContract[], ): Promise { for (const contract of contracts) { - if (await runtimeContractExists(contract)) return true; + const exists = await managedRuntimeFilesExist(contract, AGENT_KEY, { + guardOptions: GUARD_OPTIONS, + auditOptions: AUDIT_OPTIONS, + }); + if (exists) return true; } return false; } diff --git a/src/plugins/jsonc-edit.ts b/src/plugins/jsonc-edit.ts new file mode 100644 index 0000000..2275af6 --- /dev/null +++ b/src/plugins/jsonc-edit.ts @@ -0,0 +1,23 @@ +import { applyEdits, modify, type FormattingOptions, type JSONPath } from 'jsonc-parser'; + +function formatting(raw: string): FormattingOptions { + const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; + const insertSpaces = !indentation?.includes('\t'); + return { + eol: raw.includes('\r\n') ? '\r\n' : '\n', + insertSpaces, + tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, + }; +} + +export function changeJsonc( + raw: string, + jsonPath: JSONPath, + value: unknown, + isArrayInsertion = false, +): string { + return applyEdits(raw, modify(raw, jsonPath, value, { + formattingOptions: formatting(raw), + isArrayInsertion, + })); +} diff --git a/src/plugins/kimi-command.ts b/src/plugins/kimi-command.ts index 6797fbf..d0bb43b 100644 --- a/src/plugins/kimi-command.ts +++ b/src/plugins/kimi-command.ts @@ -1,140 +1,21 @@ -import os from 'node:os'; import path from 'node:path'; +import { managedScriptReference, type ManagedScriptReference } from './common.js'; +import { + encodedWindowsCommand, + isNodeExecutable, + parseEncodedWindowsCommand, + parseLegacyWindowsCommand, + parsePosixCommand, + posixSource, +} from './shell-command.js'; -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export interface KimiRuntimeReference { - readonly agentId: string; - readonly scriptPath: string; -} - -export function sameKimiPath(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 sameKimiAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function windowsPowerShellPath(): string { - const configuredRoot = process.platform === 'win32' ? process.env.SystemRoot : undefined; - const systemRoot = configuredRoot - && path.win32.isAbsolute(configuredRoot) - && !/["%\r\n]/.test(configuredRoot) - ? configuredRoot - : 'C:\\Windows'; - return path.win32.join( - systemRoot, - 'System32', - 'WindowsPowerShell', - 'v1.0', - 'powershell.exe', - ); -} - -function windowsCommand(scriptPath: string): string { - const source = `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - const encoded = Buffer.from(source, 'utf16le').toString('base64'); - return `"${windowsPowerShellPath()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}`; -} +export type KimiRuntimeReference = ManagedScriptReference; export function buildKimiCommand(scriptPath: string): string { if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { throw new Error('Kimi hook commands require absolute executable and script paths'); } - return process.platform === 'win32' - ? windowsCommand(scriptPath) - : `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; -} - -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 parsePosixCommand(command: string): readonly [string, string] | 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 parsePowerShellSource(source: string): readonly [string, string] | undefined { - if (!source.startsWith('& ')) return undefined; - const executable = readPowerShellArgument(source, 2); - if (!executable || source[executable.next] !== ' ') return undefined; - const script = readPowerShellArgument(source, executable.next + 1); - if (!script || source.slice(script.next) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - -function parseWindowsCommand(command: string): readonly [string, string] | undefined { - const match = /^"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/.exec(command); - if (!match - || !path.win32.isAbsolute(match[1]) - || path.win32.basename(match[1]).toLowerCase() !== 'powershell.exe') return undefined; - const encoded = match[2]; - const buffer = Buffer.from(encoded, 'base64'); - if (buffer.length % 2 !== 0 || buffer.toString('base64') !== encoded) return undefined; - return parsePowerShellSource(buffer.toString('utf16le')); -} - -function parseLegacyWindowsCommand(command: string): readonly [string, string] | undefined { - const match = /^"([^"\r\n]+)" "([^"\r\n]+)"$/.exec(command); - return match ? [match[1], match[2]] : undefined; -} - -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath).toLowerCase(); - return basename === 'node' || basename === 'node.exe'; + return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath); } export function kimiRuntimeReference( @@ -142,18 +23,8 @@ export function kimiRuntimeReference( scriptName: string, ): KimiRuntimeReference | undefined { const parsed = parsePosixCommand(command) - ?? parseWindowsCommand(command) + ?? parseEncodedWindowsCommand(command) ?? parseLegacyWindowsCommand(command); - if (!parsed - || !path.isAbsolute(parsed[0]) - || !path.isAbsolute(parsed[1]) - || !isNodeExecutable(parsed[0]) - || path.basename(parsed[1]) !== scriptName) return undefined; - const agentDirectory = path.dirname(parsed[1]); - if (!sameKimiPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { - return undefined; - } - const agentId = path.basename(agentDirectory); - if (!agentId || agentId === '.' || agentId === '..') return undefined; - return { agentId, scriptPath: parsed[1] }; + if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined; + return managedScriptReference(parsed[1], scriptName); } diff --git a/src/plugins/kimi-contract.ts b/src/plugins/kimi-contract.ts index efc9a86..d4c20e6 100644 --- a/src/plugins/kimi-contract.ts +++ b/src/plugins/kimi-contract.ts @@ -1,9 +1,10 @@ +import { sameAgentId } from './common.js'; import { buildKimiCommand, kimiRuntimeReference, - sameKimiAgentId, type KimiRuntimeReference, } from './kimi-command.js'; +import { isObject, type JsonObject } from './strict-json.js'; export const AGENT_KEY = 'kimi'; export const GUARD_SCRIPT = 'guard.js'; @@ -34,7 +35,7 @@ export const STABLE_EVENTS = new Set([ ]); export const LEGACY_EVENTS = new Set(SHARED_EVENTS); -export type TomlObject = Record; +export type TomlObject = JsonObject; export type ManagedKimiEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure'; export interface KimiHook extends TomlObject { @@ -75,14 +76,6 @@ export interface KimiRuntimeContract { readonly configPath: string; } -function isObject(value: unknown): value is TomlObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasOwn(value: TomlObject, key: string): boolean { - return Object.prototype.hasOwnProperty.call(value, key); -} - function exactManagedKeys(hook: KimiHook): boolean { return Object.keys(hook).sort().join('|') === 'command|event|timeout'; } @@ -194,7 +187,7 @@ async function renderHookSource( if (document.usesHookTables) return patchToml(document.raw ?? '', nextRoot); let base = document.raw ?? ''; - if (hasOwn(document.root, 'hooks')) { + if (Object.hasOwn(document.root, 'hooks')) { const rootWithoutHooks = { ...document.root }; delete rootWithoutHooks.hooks; base = patchToml(base, rootWithoutHooks); @@ -248,7 +241,7 @@ export function removeManagedKimiHooks( if (!expected) return true; const managedId = managedAgentId(hook, expected[0], expected[1]); if (!managedId) return true; - return agentId !== undefined && !sameKimiAgentId(managedId, agentId); + return agentId !== undefined && !sameAgentId(managedId, agentId); }); } diff --git a/src/plugins/kimi-installation.ts b/src/plugins/kimi-installation.ts index 6086a23..30752c2 100644 --- a/src/plugins/kimi-installation.ts +++ b/src/plugins/kimi-installation.ts @@ -23,10 +23,6 @@ import { const DISPLAY_NAME = 'Kimi'; -export type KimiRuntimePaths = ManagedRuntimePaths; -export type PreparedKimiInstallation = PreparedManagedInstallation; -export type { RenameFile }; - function hookLocations(documents: readonly KimiConfigDocument[]) { return documents.map((document) => ({ directoryLabel: document.contract.directoryLabel, @@ -37,7 +33,7 @@ function hookLocations(documents: readonly KimiConfigDocument[]) { export async function preflightKimiInstallation( config: InstallConfig, documents: readonly KimiConfigDocument[], -): Promise { +): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, hookLocations: hookLocations(documents), @@ -56,7 +52,7 @@ function renderedSource(rendered: RenderedKimiDocument): string { export async function prepareKimiInstallation( config: InstallConfig, rendered: readonly RenderedKimiDocument[], -): Promise { +): Promise { return prepareManagedInstallation({ agentKey: AGENT_KEY, displayName: DISPLAY_NAME, @@ -73,7 +69,7 @@ export async function prepareKimiInstallation( } export async function commitKimiInstallation( - prepared: PreparedKimiInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/kimi-io.ts b/src/plugins/kimi-io.ts index 3d2d4eb..d3a9839 100644 --- a/src/plugins/kimi-io.ts +++ b/src/plugins/kimi-io.ts @@ -1,6 +1,7 @@ import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { asError, errorMessage, hasCode, samePath } from './common.js'; import { AGENT_KEY, LEGACY_EVENTS, @@ -10,23 +11,9 @@ import { type KimiConfigDocument, type KimiContract, type KimiRuntimeContract, - type TomlObject, } from './kimi-contract.js'; -import { sameKimiAgentId, sameKimiPath } from './kimi-command.js'; import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject } from './strict-json.js'; - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function hasCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code; -} +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; async function pathEntryExists(filePath: string, label: string): Promise { try { @@ -72,7 +59,7 @@ export async function resolveKimiContracts(): Promise { const legacyHome = path.join(home, '.kimi'); const stable = stableContract(path.join(stableHome, 'config.toml')); const legacy = legacyContract(path.join(legacyHome, 'config.toml')); - if (sameKimiPath(stable.configPath, legacy.configPath)) return [stable]; + if (samePath(stable.configPath, legacy.configPath)) return [stable]; const stableDetected = explicitHome !== undefined || await pathEntryExists(stableHome, 'Kimi Code home'); @@ -96,82 +83,11 @@ export async function readKimiDocuments(): Promise { return documents; } -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: TomlObject, - contract: KimiRuntimeContract, - 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 (!sameKimiAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Kimi 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: asError(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}`); - } -} - -async function runtimeContractExists(contract: KimiRuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!sameKimiPath(path.dirname(agentDirectory), runtimeRoot) - || !sameKimiPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) 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] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config'), - readPhysicalFile(keyPath, 'Elydora private key'), - 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.length > 0 && audit.contents.length > 0; -} - export async function kimiRuntimeFilesExist( contracts: readonly KimiRuntimeContract[], ): Promise { for (const contract of contracts) { - if (await runtimeContractExists(contract)) return true; + if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/letta-command.ts b/src/plugins/letta-command.ts index 24a2065..0a61d59 100644 --- a/src/plugins/letta-command.ts +++ b/src/plugins/letta-command.ts @@ -1,10 +1,12 @@ -import os from 'node:os'; import path from 'node:path'; - -interface ParsedArgument { - readonly value: string; - readonly next: number; -} +import { managedScriptReference } from './common.js'; +import { + isNodeExecutable, + parsePosixCommand, + parsePowerShellSource, + posixSource, + powerShellSource, +} from './shell-command.js'; export interface LettaRuntimeReference { readonly agentId: string; @@ -12,88 +14,11 @@ export interface LettaRuntimeReference { readonly scriptPath: string; } -export function sameLettaPath(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 sameLettaAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - export function buildLettaCommand(scriptPath: string): string { if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { throw new Error('Letta Code hook commands require absolute executable and script paths'); } - if (process.platform === 'win32') { - return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - } - return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; -} - -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 parsePosixCommand(command: string): readonly [string, string] | 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 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) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; + return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath); } function legacyScriptPath(command: string): string | undefined { @@ -102,39 +27,16 @@ function legacyScriptPath(command: string): string | undefined { return scriptPath.includes('"') ? undefined : scriptPath; } -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath).toLowerCase(); - return basename === 'node' || basename === 'node.exe'; -} - -function runtimeReference( - scriptPath: string, - scriptName: string, - executablePath?: string, -): LettaRuntimeReference | undefined { - if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined; - const agentDirectory = path.dirname(scriptPath); - if (!sameLettaPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { - return undefined; - } - const agentId = path.basename(agentDirectory); - if (!agentId || agentId === '.' || agentId === '..') return undefined; - return { agentId, executablePath, scriptPath }; -} - export function lettaRuntimeReference( command: string, scriptName: string, ): LettaRuntimeReference | undefined { const parsed = process.platform === 'win32' - ? parsePowerShellCommand(command) + ? parsePowerShellSource(command) : parsePosixCommand(command); - if (parsed - && path.isAbsolute(parsed[0]) - && isNodeExecutable(parsed[0])) { - return runtimeReference(parsed[1], scriptName, parsed[0]); - } - return undefined; + if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined; + const reference = managedScriptReference(parsed[1], scriptName); + return reference ? { ...reference, executablePath: parsed[0] } : undefined; } export function lettaLegacyRuntimeReference( @@ -142,5 +44,5 @@ export function lettaLegacyRuntimeReference( scriptName: string, ): LettaRuntimeReference | undefined { const legacy = legacyScriptPath(command); - return legacy ? runtimeReference(legacy, scriptName) : undefined; + return legacy ? managedScriptReference(legacy, scriptName) : undefined; } diff --git a/src/plugins/letta-config.ts b/src/plugins/letta-config.ts index 1bbd6b6..ebe5e35 100644 --- a/src/plugins/letta-config.ts +++ b/src/plugins/letta-config.ts @@ -1,9 +1,5 @@ -import { - applyEdits, - modify, - type FormattingOptions, - type JSONPath, -} from 'jsonc-parser'; +import type { JSONPath } from 'jsonc-parser'; +import { changeJsonc } from './jsonc-edit.js'; import type { FileSnapshot } from './managed-files.js'; import { MANAGED_EVENTS, @@ -73,28 +69,6 @@ export function createLettaDocument( }); } -function formatting(raw: string): FormattingOptions { - const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; - const insertSpaces = !indentation?.includes('\t'); - return { - eol: raw.includes('\r\n') ? '\r\n' : '\n', - insertSpaces, - tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, - }; -} - -function change( - raw: string, - jsonPath: JSONPath, - value: unknown, - isArrayInsertion = false, -): string { - return applyEdits(raw, modify(raw, jsonPath, value, { - formattingOptions: formatting(raw), - isArrayInsertion, - })); -} - function currentDocument(document: LettaDocument, raw: string): LettaDocument { return parseLettaDocument({ kind: document.kind, @@ -124,25 +98,25 @@ function removeManagedEntries( for (const removal of eventRemovals) { const groupPath: JSONPath = ['hooks', event, removal.groupIndex]; if (removal.removeGroup) { - raw = change(raw, groupPath, undefined); + raw = changeJsonc(raw, groupPath, undefined); continue; } for (const handlerIndex of [...removal.handlerIndexes].sort( (left, right) => right - left, )) { - raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined); } } if (eventRemovals.length > 0) { const current = currentDocument(document, raw); if (groupsForEvent(current.hooks, event).length === 0) { - raw = change(raw, ['hooks', event], undefined); + raw = changeJsonc(raw, ['hooks', event], undefined); } } } const current = currentDocument(document, raw); if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { - raw = change(raw, ['hooks'], undefined); + raw = changeJsonc(raw, ['hooks'], undefined); } return raw; } @@ -156,9 +130,9 @@ function appendGroup( const current = currentDocument(document, raw); const groups = groupsForEvent(current.hooks, event); if (groups.length > 0) { - return change(raw, ['hooks', event, groups.length], group, true); + return changeJsonc(raw, ['hooks', event, groups.length], group, true); } - return change(raw, ['hooks', event], [group]); + return changeJsonc(raw, ['hooks', event], [group]); } export function renderLettaDocument( diff --git a/src/plugins/letta-contract.ts b/src/plugins/letta-contract.ts index 3a6f5b7..be7701f 100644 --- a/src/plugins/letta-contract.ts +++ b/src/plugins/letta-contract.ts @@ -1,9 +1,8 @@ +import { sameAgentId, samePath } from './common.js'; import { buildLettaCommand, lettaLegacyRuntimeReference, lettaRuntimeReference, - sameLettaAgentId, - sameLettaPath, type LettaRuntimeReference, } from './letta-command.js'; import { isObject, type JsonObject } from './strict-json.js'; @@ -220,7 +219,7 @@ export function managedLettaRemovals( eventGroups(hooks, event).forEach((group, groupIndex) => { const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { const reference = managedReference(handler, scriptName); - return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId)) + return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId)) ? [handlerIndex] : []; }); @@ -248,7 +247,7 @@ function referencesForEvent( const reference = exactCurrentReference(handler, scriptName); if (!reference || !reference.executablePath - || !sameLettaPath(reference.executablePath, process.execPath)) continue; + || !samePath(reference.executablePath, process.execPath)) continue; const key = process.platform === 'win32' ? reference.agentId.toLowerCase() : reference.agentId; @@ -270,7 +269,7 @@ export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] const post = posts.get(key); const failure = failures.get(key); if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue; - if (!sameLettaPath(post[0].scriptPath, failure[0].scriptPath)) continue; + if (!samePath(post[0].scriptPath, failure[0].scriptPath)) continue; contracts.push({ agentId: guard[0].agentId, guardPath: guard[0].scriptPath, diff --git a/src/plugins/letta-installation.ts b/src/plugins/letta-installation.ts index fce616f..edab00a 100644 --- a/src/plugins/letta-installation.ts +++ b/src/plugins/letta-installation.ts @@ -6,7 +6,7 @@ import { GUARD_SCRIPT, LETTA_AUDIT_OPTIONS, } from './letta-contract.js'; -import { sameLettaPath } from './letta-command.js'; +import { MAX_SOURCE_BYTES, samePath } from './common.js'; import { lettaDocumentLabel, type RenderedLettaDocument, @@ -31,16 +31,11 @@ import { const DISPLAY_NAME = 'Letta Code'; const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - -export type LettaRuntimePaths = ManagedRuntimePaths; -export type PreparedLettaInstallation = PreparedManagedInstallation; -export type { RenameFile }; export async function preflightLettaInstallation( config: InstallConfig, sources: LettaSources, -): Promise { +): Promise { requireLettaHooksEnabled(sources); return preflightManagedInstallation({ agentKey: AGENT_KEY, @@ -54,7 +49,7 @@ export async function preflightLettaInstallation( function readOnlyPreconditions(sources: LettaSources, changedPath?: string) { return sources.preconditions.filter((condition) => ( - changedPath === undefined || !sameLettaPath(condition.filePath, changedPath) + changedPath === undefined || !samePath(condition.filePath, changedPath) )); } @@ -62,7 +57,7 @@ export async function prepareLettaInstallation( config: InstallConfig, sources: LettaSources, rendered: RenderedLettaDocument, -): Promise { +): Promise { await preflightLettaInstallation(config, sources); const settingsSource = rendered.next ?? rendered.document.raw; const prepared = await prepareManagedInstallation({ @@ -89,7 +84,7 @@ export async function prepareLettaInstallation( } export async function commitLettaInstallation( - prepared: PreparedLettaInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/letta-sources.ts b/src/plugins/letta-sources.ts index 4d74a4a..b8d0112 100644 --- a/src/plugins/letta-sources.ts +++ b/src/plugins/letta-sources.ts @@ -9,14 +9,13 @@ import { type LettaDocument, type LettaDocumentKind, } from './letta-config.js'; +import { hasCode, MAX_SOURCE_BYTES, pathKey, samePath } from './common.js'; import { inspectPhysicalDirectory, readPhysicalFile, type FileSnapshot, } from './managed-files.js'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - export interface LettaSourcePrecondition { readonly filePath: string; readonly label: string; @@ -39,15 +38,6 @@ export interface LettaSources { readonly preconditions: readonly LettaSourcePrecondition[]; } -function comparisonPath(filePath: string): string { - const resolved = path.resolve(filePath); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; -} - -function hasCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code; -} - async function canonicalPath(filePath: string): Promise { try { return await fsp.realpath(filePath); @@ -93,7 +83,7 @@ function deduplicatePreconditions( ): LettaSourcePrecondition[] { const result = new Map(); for (const value of values) { - const key = comparisonPath(value.filePath); + const key = pathKey(value.filePath); if (!result.has(key)) result.set(key, value); } return [...result.values()]; @@ -123,7 +113,7 @@ export async function readLettaSources(): Promise { const projectDirectory = path.join(workspace, '.letta'); await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory'); await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory'); - if (comparisonPath(projectDirectory) !== comparisonPath(globalDirectory)) { + if (!samePath(projectDirectory, globalDirectory)) { await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory'); } const globalPath = path.join(globalDirectory, 'settings.json'); @@ -133,7 +123,7 @@ export async function readLettaSources(): Promise { canonicalPath(workspace), canonicalPath(homeDirectory), ]); - const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome); + const projectActive = !samePath(canonicalWorkspace, canonicalHome); const global = await readDocument('global', globalPath); const project = projectActive ? await readDocument('project', projectPath) diff --git a/src/plugins/qwen-command.ts b/src/plugins/qwen-command.ts index 15bc29c..d93bdd4 100644 --- a/src/plugins/qwen-command.ts +++ b/src/plugins/qwen-command.ts @@ -1,103 +1,20 @@ -import os from 'node:os'; import path from 'node:path'; +import { managedScriptReference, type ManagedScriptReference } from './common.js'; +import { + isNodeExecutable, + parsePosixCommand, + parsePowerShellSource, + posixSource, + powerShellSource, +} from './shell-command.js'; -interface ParsedArgument { - readonly value: string; - readonly next: number; -} - -export interface QwenRuntimeReference { - readonly agentId: string; - readonly scriptPath: string; -} - -export function sameQwenPath(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 sameQwenAgentId(left: string, right: string): boolean { - return process.platform === 'win32' - ? left.toLowerCase() === right.toLowerCase() - : left === right; -} - -function quotePosix(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} +export type QwenRuntimeReference = ManagedScriptReference; export function buildQwenCommand(scriptPath: string): string { if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) { throw new Error('Qwen Code hook commands require absolute executable and script paths'); } - if (process.platform === 'win32') { - return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`; - } - return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`; -} - -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 parsePosixCommand(command: string): readonly [string, string] | 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 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) !== '; exit $LASTEXITCODE') return undefined; - return [executable.value, script.value]; -} - -function isNodeExecutable(filePath: string): boolean { - const basename = path.basename(filePath).toLowerCase(); - return basename === 'node' || basename === 'node.exe'; + return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath); } export function qwenRuntimeReference( @@ -105,18 +22,8 @@ export function qwenRuntimeReference( scriptName: string, ): QwenRuntimeReference | undefined { const parsed = process.platform === 'win32' - ? parsePowerShellCommand(command) + ? parsePowerShellSource(command) : parsePosixCommand(command); - if (!parsed - || !path.isAbsolute(parsed[0]) - || !isNodeExecutable(parsed[0]) - || !path.isAbsolute(parsed[1]) - || path.basename(parsed[1]) !== scriptName) return undefined; - const agentDirectory = path.dirname(parsed[1]); - if (!sameQwenPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) { - return undefined; - } - const agentId = path.basename(agentDirectory); - if (!agentId || agentId === '.' || agentId === '..') return undefined; - return { agentId, scriptPath: parsed[1] }; + if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined; + return managedScriptReference(parsed[1], scriptName); } diff --git a/src/plugins/qwen-config.ts b/src/plugins/qwen-config.ts index d02d57f..d70000b 100644 --- a/src/plugins/qwen-config.ts +++ b/src/plugins/qwen-config.ts @@ -1,25 +1,15 @@ -import { - applyEdits, - modify, - parse, - parseTree, - printParseErrorCode, - type FormattingOptions, - type JSONPath, - type Node, - type ParseError, -} from 'jsonc-parser'; +import type { JSONPath } from 'jsonc-parser'; import type { FileSnapshot } from './managed-files.js'; +import { changeJsonc } from './jsonc-edit.js'; import { MANAGED_EVENTS, managedQwenRemovals, readQwenHooks, - type JsonObject, type ManagedQwenEvent, type QwenGroup, type QwenHooks, - isObject, } from './qwen-contract.js'; +import { isObject, parseCommentedJsonObject, type JsonObject } from './strict-json.js'; export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora'; @@ -53,50 +43,6 @@ interface DocumentOptions { readonly snapshot?: FileSnapshot; } -function rejectDuplicateKeys( - node: Node | undefined, - label: string, - location: string[] = [], -): void { - if (!node) return; - if (node.type === 'object') { - const keys = new Set(); - for (const property of node.children ?? []) { - const key = String(property.children?.[0]?.value); - const childLocation = [...location, key]; - if (keys.has(key)) { - throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`); - } - keys.add(key); - rejectDuplicateKeys(property.children?.[1], label, childLocation); - } - return; - } - if (node.type === 'array') { - for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location); - } -} - -export function parseQwenJsoncObject(raw: string, label: string): JsonObject { - const errors: ParseError[] = []; - const value: unknown = parse(raw, errors, { - allowTrailingComma: false, - disallowComments: false, - }); - if (errors.length > 0) { - const details = errors - .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) - .join(', '); - throw new Error(`Failed to parse ${label}: ${details}`); - } - if (!isObject(value)) throw new Error(`${label} must contain a JSON object`); - rejectDuplicateKeys(parseTree(raw, [], { - allowTrailingComma: false, - disallowComments: false, - }), label); - return value; -} - export function qwenSourceLabel(kind: QwenDocumentKind): string { switch (kind) { case 'system-defaults': return 'Qwen Code system defaults'; @@ -122,7 +68,7 @@ function readFolderTrustEnabled(root: JsonObject, label: string): boolean | unde export function parseQwenDocument(options: DocumentOptions): QwenDocument { const label = `${qwenSourceLabel(options.kind)} at ${options.filePath}`; - const root = parseQwenJsoncObject(options.raw, label); + const root = parseCommentedJsonObject(options.raw, label); if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') { throw new Error(`${label} field "disableAllHooks" must be a boolean`); } @@ -150,28 +96,6 @@ export function createQwenDocument( }); } -function formatting(raw: string): FormattingOptions { - const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1]; - const insertSpaces = !indentation?.includes('\t'); - return { - eol: raw.includes('\r\n') ? '\r\n' : '\n', - insertSpaces, - tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1, - }; -} - -function change( - raw: string, - jsonPath: JSONPath, - value: unknown, - isArrayInsertion = false, -): string { - return applyEdits(raw, modify(raw, jsonPath, value, { - formattingOptions: formatting(raw), - isArrayInsertion, - })); -} - function currentDocument(document: QwenDocument, raw: string): QwenDocument { return parseQwenDocument({ kind: document.kind, @@ -195,25 +119,25 @@ function removeManagedEntries( for (const removal of eventRemovals) { const groupPath: JSONPath = ['hooks', event, removal.groupIndex]; if (removal.removeGroup) { - raw = change(raw, groupPath, undefined); + raw = changeJsonc(raw, groupPath, undefined); continue; } for (const handlerIndex of [...removal.handlerIndexes].sort( (left, right) => right - left, )) { - raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined); + raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined); } } if (eventRemovals.length > 0) { const current = currentDocument(document, raw); if ((current.hooks[event] ?? []).length === 0) { - raw = change(raw, ['hooks', event], undefined); + raw = changeJsonc(raw, ['hooks', event], undefined); } } } const current = currentDocument(document, raw); if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) { - raw = change(raw, ['hooks'], undefined); + raw = changeJsonc(raw, ['hooks'], undefined); } return raw; } @@ -226,9 +150,9 @@ function appendGroup( ): string { const current = currentDocument(document, raw); if (current.hooks[event]) { - return change(raw, ['hooks', event, current.hooks[event].length], group, true); + return changeJsonc(raw, ['hooks', event, current.hooks[event].length], group, true); } - return change(raw, ['hooks', event], [group]); + return changeJsonc(raw, ['hooks', event], [group]); } export function renderQwenDocument( diff --git a/src/plugins/qwen-contract.ts b/src/plugins/qwen-contract.ts index af10742..dcce60d 100644 --- a/src/plugins/qwen-contract.ts +++ b/src/plugins/qwen-contract.ts @@ -1,8 +1,7 @@ +import { sameAgentId, samePath } from './common.js'; import { buildQwenCommand, qwenRuntimeReference, - sameQwenAgentId, - sameQwenPath, type QwenRuntimeReference, } from './qwen-command.js'; import { isObject, type JsonObject } from './strict-json.js'; @@ -283,7 +282,7 @@ export function managedQwenRemovals( (hooks[event] ?? []).forEach((group, groupIndex) => { const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => { const reference = managedReference(handler, scriptName, hookName, true); - return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId)) + return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId)) ? [handlerIndex] : []; }); @@ -334,7 +333,7 @@ export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] { const post = posts.get(key); const failure = failures.get(key); if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue; - if (!sameQwenPath(post[0].scriptPath, failure[0].scriptPath)) continue; + if (!samePath(post[0].scriptPath, failure[0].scriptPath)) continue; contracts.push({ agentId: guard[0].agentId, guardPath: guard[0].scriptPath, @@ -343,5 +342,3 @@ export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] { } return contracts; } - -export { isObject, type JsonObject }; diff --git a/src/plugins/qwen-installation.ts b/src/plugins/qwen-installation.ts index b1f2275..2b06905 100644 --- a/src/plugins/qwen-installation.ts +++ b/src/plugins/qwen-installation.ts @@ -5,7 +5,7 @@ import { AUDIT_SCRIPT, GUARD_SCRIPT, } from './qwen-contract.js'; -import { sameQwenPath } from './qwen-command.js'; +import { MAX_SOURCE_BYTES, samePath } from './common.js'; import { qwenDocumentLabel, type RenderedQwenDocument, @@ -30,16 +30,11 @@ import { const DISPLAY_NAME = 'Qwen Code'; const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory'; -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; - -export type QwenRuntimePaths = ManagedRuntimePaths; -export type PreparedQwenInstallation = PreparedManagedInstallation; -export type { RenameFile }; export async function preflightQwenInstallation( config: InstallConfig, sources: QwenSources, -): Promise { +): Promise { requireQwenHooksEnabled(sources); return preflightManagedInstallation({ agentKey: AGENT_KEY, @@ -53,7 +48,7 @@ export async function preflightQwenInstallation( function readOnlyPreconditions(sources: QwenSources, changedPath?: string) { return sources.preconditions.filter((condition) => ( - changedPath === undefined || !sameQwenPath(condition.filePath, changedPath) + changedPath === undefined || !samePath(condition.filePath, changedPath) )); } @@ -61,7 +56,7 @@ export async function prepareQwenInstallation( config: InstallConfig, sources: QwenSources, rendered: RenderedQwenDocument, -): Promise { +): Promise { await preflightQwenInstallation(config, sources); const settingsSource = rendered.next ?? rendered.document.raw; const prepared = await prepareManagedInstallation({ @@ -88,7 +83,7 @@ export async function prepareQwenInstallation( } export async function commitQwenInstallation( - prepared: PreparedQwenInstallation, + prepared: PreparedManagedInstallation, renameFile?: RenameFile, ): Promise { await commitManagedInstallation(prepared, renameFile); diff --git a/src/plugins/qwen-io.ts b/src/plugins/qwen-io.ts index 86743f4..998c2dc 100644 --- a/src/plugins/qwen-io.ts +++ b/src/plugins/qwen-io.ts @@ -1,99 +1,11 @@ -import os from 'node:os'; -import path from 'node:path'; -import { generateGuardScript } from './guard-template.js'; -import { generateHookScript } from './hook-template.js'; -import { - AGENT_KEY, - type QwenRuntimeContract, -} from './qwen-contract.js'; -import { sameQwenAgentId, sameQwenPath } from './qwen-command.js'; -import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; -import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; - -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: QwenRuntimeContract, - 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 (!sameQwenAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { - throw new Error(`Elydora runtime identity does not match Qwen Code 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}`); - } -} - -async function runtimeContractExists(contract: QwenRuntimeContract): Promise { - const runtimeRoot = path.join(os.homedir(), '.elydora'); - const agentDirectory = path.dirname(contract.guardPath); - if (!sameQwenPath(path.dirname(agentDirectory), runtimeRoot) - || !sameQwenPath(contract.auditPath, path.join(agentDirectory, 'hook.js'))) 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] = await Promise.all([ - readPhysicalFile(configPath, 'Elydora runtime config'), - readPhysicalFile(keyPath, 'Elydora private key'), - 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 }, - ); -} +import { AGENT_KEY, type QwenRuntimeContract } from './qwen-contract.js'; +import { managedRuntimeFilesExist } from './managed-runtime-status.js'; export async function qwenRuntimeFilesExist( contracts: readonly QwenRuntimeContract[], ): Promise { for (const contract of contracts) { - if (await runtimeContractExists(contract)) return true; + if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true; } return false; } diff --git a/src/plugins/qwen-sources.ts b/src/plugins/qwen-sources.ts index e93854a..25f5b15 100644 --- a/src/plugins/qwen-sources.ts +++ b/src/plugins/qwen-sources.ts @@ -2,18 +2,17 @@ import os from 'node:os'; import path from 'node:path'; import fsp from 'node:fs/promises'; import { parse as parseDotenv } from 'dotenv'; +import { MAX_SOURCE_BYTES, pathKey, samePath } from './common.js'; import { createQwenDocument, parseQwenDocument, - parseQwenJsoncObject, qwenDocumentLabel, qwenSourceLabel, type QwenDocument, type QwenDocumentKind, } from './qwen-config.js'; import { readPhysicalFile, type FileSnapshot } from './managed-files.js'; - -const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +import { parseCommentedJsonObject } from './strict-json.js'; const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const; export interface QwenSourcePrecondition { @@ -94,7 +93,7 @@ async function resolveQwenRouting(): Promise { const readCandidate = async (filePath: string): Promise => { const resolved = path.resolve(filePath); - const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved; + const key = pathKey(resolved); if (visited.has(key)) return; visited.add(key); const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment'); @@ -172,13 +171,8 @@ async function canonicalPath(filePath: string): Promise { } } -function comparisonPath(filePath: string): string { - const resolved = path.resolve(filePath); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; -} - function isWithin(child: string, parent: string): boolean { - const relative = path.relative(comparisonPath(parent), comparisonPath(child)); + const relative = path.relative(pathKey(parent), pathKey(child)); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } @@ -202,7 +196,7 @@ async function workspaceTrust( original: snapshot, }; if (!snapshot) return { trusted: true, precondition }; - const rules = parseQwenJsoncObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`); + const rules = parseCommentedJsonObject(snapshot.contents, `Qwen Code trusted folders at ${filePath}`); for (const [rulePath, level] of Object.entries(rules)) { if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) { throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`); @@ -217,7 +211,7 @@ async function workspaceTrust( } for (const [rulePath, level] of Object.entries(rules)) { if (level === 'DO_NOT_TRUST' - && comparisonPath(workspace) === comparisonPath(await canonicalPath(rulePath))) { + && samePath(workspace, await canonicalPath(rulePath))) { return { trusted: false, precondition }; } } @@ -256,7 +250,7 @@ function deduplicatePreconditions( ): QwenSourcePrecondition[] { const result = new Map(); for (const value of values) { - const key = comparisonPath(value.filePath); + const key = pathKey(value.filePath); if (!result.has(key)) result.set(key, value); } return [...result.values()]; @@ -276,7 +270,7 @@ export async function readQwenSources(): Promise { canonicalPath(process.cwd()), canonicalPath(os.homedir()), ]); - const workspaceActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome); + const workspaceActive = !samePath(canonicalWorkspace, canonicalHome); const workspace = workspaceActive ? await readDocument('workspace', workspacePath) : createQwenDocument('workspace', workspacePath); diff --git a/src/plugins/strict-json.ts b/src/plugins/strict-json.ts index 238f172..8121e8e 100644 --- a/src/plugins/strict-json.ts +++ b/src/plugins/strict-json.ts @@ -66,6 +66,13 @@ export function parseStrictJsonObject(raw: string, label: string): JsonObject { }); } +export function parseCommentedJsonObject(raw: string, label: string): JsonObject { + return parseJsonObject(raw, label, { + allowTrailingComma: false, + disallowComments: false, + }); +} + export function parseStrictJsoncObject(raw: string, label: string): JsonObject { return parseJsonObject(raw, label, { allowTrailingComma: true,