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
This commit is contained in:
1 parent
db4a6ffb62
commit
3b5cbd6e17
24 files changed
+209
-1084
No files matched your search
+24
-5
@@ -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/<agentId>/<scriptName>.
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string>();
|
||||
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(
|
||||
|
||||
+14
-143
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<GrokRuntimePaths> {
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
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<PreparedGrokInstallation> {
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
|
||||
+8
-74
@@ -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<GrokDocument> {
|
||||
: 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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
+14
-143
@@ -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);
|
||||
}
|
||||
@@ -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<string>([
|
||||
]);
|
||||
export const LEGACY_EVENTS = new Set<string>(SHARED_EVENTS);
|
||||
|
||||
export type TomlObject = Record<string, unknown>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<KimiRuntimePaths> {
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
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<PreparedKimiInstallation> {
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
|
||||
+4
-88
@@ -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<boolean> {
|
||||
try {
|
||||
@@ -72,7 +59,7 @@ export async function resolveKimiContracts(): Promise<KimiContract[]> {
|
||||
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<KimiConfigDocument[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await runtimeContractExists(contract)) return true;
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+14
-112
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LettaRuntimePaths> {
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
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<PreparedLettaInstallation> {
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
|
||||
@@ -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<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
@@ -93,7 +83,7 @@ function deduplicatePreconditions(
|
||||
): LettaSourcePrecondition[] {
|
||||
const result = new Map<string, LettaSourcePrecondition>();
|
||||
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<LettaSources> {
|
||||
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<LettaSources> {
|
||||
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)
|
||||
|
||||
+13
-106
@@ -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);
|
||||
}
|
||||
+10
-86
@@ -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<string>();
|
||||
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(
|
||||
|
||||
@@ -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 };
|
||||
@@ -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<QwenRuntimePaths> {
|
||||
): Promise<ManagedRuntimePaths> {
|
||||
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<PreparedQwenInstallation> {
|
||||
): Promise<PreparedManagedInstallation> {
|
||||
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<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
|
||||
+3
-91
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await runtimeContractExists(contract)) return true;
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -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<RoutingResult> {
|
||||
|
||||
const readCandidate = async (filePath: string): Promise<void> => {
|
||||
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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string, QwenSourcePrecondition>();
|
||||
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<QwenSources> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user