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';
|
import path from 'node:path';
|
||||||
|
|
||||||
export const MAX_SECRET_BYTES = 64 * 1024;
|
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;
|
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 {
|
export function samePath(left: string, right: string): boolean {
|
||||||
const normalizedLeft = path.resolve(left);
|
return pathKey(left) === pathKey(right);
|
||||||
const normalizedRight = path.resolve(right);
|
|
||||||
return process.platform === 'win32'
|
|
||||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
||||||
: normalizedLeft === normalizedRight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameAgentId(left: unknown, right: string): boolean {
|
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.toLowerCase() === right.toLowerCase()
|
||||||
: left === right);
|
: 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 {
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
applyEdits,
|
import { changeJsonc } from './jsonc-edit.js';
|
||||||
modify,
|
|
||||||
type FormattingOptions,
|
|
||||||
type JSONPath,
|
|
||||||
} from 'jsonc-parser';
|
|
||||||
import type { FileSnapshot } from './managed-files.js';
|
import type { FileSnapshot } from './managed-files.js';
|
||||||
import type { DroidPolicyState } from './droid-policy.js';
|
import type { DroidPolicyState } from './droid-policy.js';
|
||||||
import {
|
import {
|
||||||
@@ -158,23 +154,6 @@ function eventPath(document: DroidDocument, event: string): JSONPath {
|
|||||||
return [...document.basePath, event];
|
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 {
|
function currentDocument(document: DroidDocument, raw: string): DroidDocument {
|
||||||
return parseDocument({
|
return parseDocument({
|
||||||
exists: document.exists,
|
exists: document.exists,
|
||||||
@@ -194,17 +173,17 @@ function removeManagedEntries(document: DroidDocument, raw: string, agentId?: st
|
|||||||
for (const removal of eventRemovals) {
|
for (const removal of eventRemovals) {
|
||||||
const groupPath = [...eventPath(document, event), removal.groupIndex];
|
const groupPath = [...eventPath(document, event), removal.groupIndex];
|
||||||
if (removal.removeGroup) {
|
if (removal.removeGroup) {
|
||||||
raw = change(raw, groupPath, undefined);
|
raw = changeJsonc(raw, groupPath, undefined);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const handlerIndex of [...removal.handlerIndexes].sort((left, right) => right - left)) {
|
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) {
|
if (eventRemovals.length > 0) {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if ((current.hooks[event] ?? []).length === 0) {
|
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);
|
const current = currentDocument(document, raw);
|
||||||
if (Object.prototype.hasOwnProperty.call(current.hooks, event)) {
|
if (Object.prototype.hasOwnProperty.call(current.hooks, event)) {
|
||||||
const groups = 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 {
|
function hookFileIsEmpty(document: DroidDocument, raw: string): boolean {
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
applyEdits,
|
|
||||||
modify,
|
|
||||||
parse,
|
|
||||||
parseTree,
|
|
||||||
printParseErrorCode,
|
|
||||||
type FormattingOptions,
|
|
||||||
type JSONPath,
|
|
||||||
type Node,
|
|
||||||
type ParseError,
|
|
||||||
} from 'jsonc-parser';
|
|
||||||
import {
|
import {
|
||||||
MANAGED_EVENTS,
|
MANAGED_EVENTS,
|
||||||
managedGeminiRemovals,
|
managedGeminiRemovals,
|
||||||
@@ -19,7 +9,8 @@ import {
|
|||||||
type GeminiHooks,
|
type GeminiHooks,
|
||||||
type ManagedGeminiEvent,
|
type ManagedGeminiEvent,
|
||||||
} from './gemini-contract.js';
|
} 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';
|
export const GEMINI_OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||||
|
|
||||||
@@ -46,53 +37,9 @@ interface DocumentOptions {
|
|||||||
readonly raw: string;
|
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 {
|
export function parseGeminiDocument(options: DocumentOptions): GeminiDocument {
|
||||||
const label = `Gemini CLI user settings at ${options.filePath}`;
|
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');
|
const hasHooksContainer = Object.hasOwn(root, 'hooks');
|
||||||
return {
|
return {
|
||||||
...options,
|
...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 {
|
function currentDocument(document: GeminiDocument, raw: string): GeminiDocument {
|
||||||
return parseGeminiDocument({
|
return parseGeminiDocument({
|
||||||
exists: document.exists,
|
exists: document.exists,
|
||||||
@@ -155,25 +80,25 @@ function removeManagedEntries(
|
|||||||
for (const removal of eventRemovals) {
|
for (const removal of eventRemovals) {
|
||||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||||
if (removal.removeGroup) {
|
if (removal.removeGroup) {
|
||||||
raw = change(raw, groupPath, undefined);
|
raw = changeJsonc(raw, groupPath, undefined);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||||
(left, right) => right - left,
|
(left, right) => right - left,
|
||||||
)) {
|
)) {
|
||||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eventRemovals.length > 0) {
|
if (eventRemovals.length > 0) {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if ((current.hooks[event] ?? []).length === 0) {
|
if ((current.hooks[event] ?? []).length === 0) {
|
||||||
raw = change(raw, ['hooks', event], undefined);
|
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||||
raw = change(raw, ['hooks'], undefined);
|
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -186,9 +111,9 @@ function appendGroup(
|
|||||||
): string {
|
): string {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hooks[event]) {
|
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(
|
export function renderGeminiDocument(
|
||||||
|
|||||||
+14
-143
@@ -1,140 +1,21 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
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 {
|
export type GrokRuntimeReference = ManagedScriptReference;
|
||||||
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 function buildGrokCommand(scriptPath: string): string {
|
export function buildGrokCommand(scriptPath: string): string {
|
||||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||||
throw new Error('Grok hook commands require absolute executable and script paths');
|
throw new Error('Grok hook commands require absolute executable and script paths');
|
||||||
}
|
}
|
||||||
return process.platform === 'win32'
|
return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath);
|
||||||
? 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function grokRuntimeReference(
|
export function grokRuntimeReference(
|
||||||
@@ -142,18 +23,8 @@ export function grokRuntimeReference(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
): GrokRuntimeReference | undefined {
|
): GrokRuntimeReference | undefined {
|
||||||
const parsed = parsePosixCommand(command)
|
const parsed = parsePosixCommand(command)
|
||||||
?? parseWindowsCommand(command)
|
?? parseEncodedWindowsCommand(command)
|
||||||
?? parseLegacyWindowsCommand(command);
|
?? parseLegacyWindowsCommand(command);
|
||||||
if (!parsed
|
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||||
|| !path.isAbsolute(parsed[0])
|
return managedScriptReference(parsed[1], scriptName);
|
||||||
|| !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] };
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import { sameAgentId } from './common.js';
|
||||||
import {
|
import {
|
||||||
buildGrokCommand,
|
buildGrokCommand,
|
||||||
grokRuntimeReference,
|
grokRuntimeReference,
|
||||||
sameGrokAgentId,
|
|
||||||
type GrokRuntimeReference,
|
type GrokRuntimeReference,
|
||||||
} from './grok-command.js';
|
} 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';
|
import { isObject, parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||||
|
|
||||||
export const AGENT_KEY = 'grok';
|
export const AGENT_KEY = 'grok';
|
||||||
export const GUARD_SCRIPT = 'guard.js';
|
export const GUARD_SCRIPT = 'guard.js';
|
||||||
export const AUDIT_SCRIPT = 'hook.js';
|
export const AUDIT_SCRIPT = 'hook.js';
|
||||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
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([
|
const MATCHER_REJECTING_EVENTS = new Set([
|
||||||
'SessionStart',
|
'SessionStart',
|
||||||
@@ -172,7 +176,7 @@ function removeFromGroups(
|
|||||||
const handlers = group.hooks.filter((handler) => {
|
const handlers = group.hooks.filter((handler) => {
|
||||||
const reference = managedReference(handler, scriptName);
|
const reference = managedReference(handler, scriptName);
|
||||||
return !reference
|
return !reference
|
||||||
|| (agentId !== undefined && !sameGrokAgentId(reference.agentId, agentId));
|
|| (agentId !== undefined && !sameAgentId(reference.agentId, agentId));
|
||||||
});
|
});
|
||||||
if (handlers.length > 0) result.push({ hooks: handlers });
|
if (handlers.length > 0) result.push({ hooks: handlers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import path from 'node:path';
|
|||||||
import type { InstallConfig } from './base.js';
|
import type { InstallConfig } from './base.js';
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
|
AUDIT_OPTIONS,
|
||||||
AUDIT_SCRIPT,
|
AUDIT_SCRIPT,
|
||||||
|
GUARD_OPTIONS,
|
||||||
GUARD_SCRIPT,
|
GUARD_SCRIPT,
|
||||||
type RenderedGrokDocument,
|
type RenderedGrokDocument,
|
||||||
} from './grok-contract.js';
|
} from './grok-contract.js';
|
||||||
@@ -24,14 +26,10 @@ const DISPLAY_NAME = 'Grok Build';
|
|||||||
const HOOKS_DIRECTORY_LABEL = 'Grok hooks directory';
|
const HOOKS_DIRECTORY_LABEL = 'Grok hooks directory';
|
||||||
const HOOKS_LABEL = 'Grok user hooks';
|
const HOOKS_LABEL = 'Grok user hooks';
|
||||||
|
|
||||||
export type GrokRuntimePaths = ManagedRuntimePaths;
|
|
||||||
export type PreparedGrokInstallation = PreparedManagedInstallation;
|
|
||||||
export type { RenameFile };
|
|
||||||
|
|
||||||
export async function preflightGrokInstallation(
|
export async function preflightGrokInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
hooksPath: string,
|
hooksPath: string,
|
||||||
): Promise<GrokRuntimePaths> {
|
): Promise<ManagedRuntimePaths> {
|
||||||
return preflightManagedInstallation({
|
return preflightManagedInstallation({
|
||||||
agentKey: AGENT_KEY,
|
agentKey: AGENT_KEY,
|
||||||
hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }],
|
hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }],
|
||||||
@@ -42,7 +40,7 @@ export async function preflightGrokInstallation(
|
|||||||
export async function prepareGrokInstallation(
|
export async function prepareGrokInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
rendered: RenderedGrokDocument,
|
rendered: RenderedGrokDocument,
|
||||||
): Promise<PreparedGrokInstallation> {
|
): Promise<PreparedManagedInstallation> {
|
||||||
const hooksSource = rendered.next ?? rendered.document.raw;
|
const hooksSource = rendered.next ?? rendered.document.raw;
|
||||||
if (hooksSource === undefined) throw new Error('Grok hook configuration is missing');
|
if (hooksSource === undefined) throw new Error('Grok hook configuration is missing');
|
||||||
const prepared = await prepareManagedInstallation({
|
const prepared = await prepareManagedInstallation({
|
||||||
@@ -56,8 +54,8 @@ export async function prepareGrokInstallation(
|
|||||||
source: hooksSource,
|
source: hooksSource,
|
||||||
}],
|
}],
|
||||||
config,
|
config,
|
||||||
guardOptions: { denyProtocol: 'grok' },
|
guardOptions: GUARD_OPTIONS,
|
||||||
auditOptions: { nativePayload: true },
|
auditOptions: AUDIT_OPTIONS,
|
||||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||||
const hooksDirectory = path.dirname(rendered.document.filePath);
|
const hooksDirectory = path.dirname(rendered.document.filePath);
|
||||||
return {
|
return {
|
||||||
@@ -73,7 +71,7 @@ export async function prepareGrokInstallation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function commitGrokInstallation(
|
export async function commitGrokInstallation(
|
||||||
prepared: PreparedGrokInstallation,
|
prepared: PreparedManagedInstallation,
|
||||||
renameFile?: RenameFile,
|
renameFile?: RenameFile,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await commitManagedInstallation(prepared, renameFile);
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
|||||||
+8
-74
@@ -2,14 +2,15 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
|
AUDIT_OPTIONS,
|
||||||
|
GUARD_OPTIONS,
|
||||||
createGrokDocument,
|
createGrokDocument,
|
||||||
parseGrokDocument,
|
parseGrokDocument,
|
||||||
type GrokDocument,
|
type GrokDocument,
|
||||||
type GrokRuntimeContract,
|
type GrokRuntimeContract,
|
||||||
} from './grok-contract.js';
|
} from './grok-contract.js';
|
||||||
import { sameGrokAgentId, sameGrokPath } from './grok-command.js';
|
|
||||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.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';
|
const CONFIG_FILE = 'elydora-audit.json';
|
||||||
|
|
||||||
@@ -36,82 +37,15 @@ export async function readGrokDocument(): Promise<GrokDocument> {
|
|||||||
: createGrokDocument(filePath);
|
: 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(
|
export async function grokRuntimeFilesExist(
|
||||||
contracts: readonly GrokRuntimeContract[],
|
contracts: readonly GrokRuntimeContract[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const contract of contracts) {
|
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;
|
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 path from 'node:path';
|
||||||
|
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||||
|
import {
|
||||||
|
encodedWindowsCommand,
|
||||||
|
isNodeExecutable,
|
||||||
|
parseEncodedWindowsCommand,
|
||||||
|
parseLegacyWindowsCommand,
|
||||||
|
parsePosixCommand,
|
||||||
|
posixSource,
|
||||||
|
} from './shell-command.js';
|
||||||
|
|
||||||
interface ParsedArgument {
|
export type KimiRuntimeReference = ManagedScriptReference;
|
||||||
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 function buildKimiCommand(scriptPath: string): string {
|
export function buildKimiCommand(scriptPath: string): string {
|
||||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||||
throw new Error('Kimi hook commands require absolute executable and script paths');
|
throw new Error('Kimi hook commands require absolute executable and script paths');
|
||||||
}
|
}
|
||||||
return process.platform === 'win32'
|
return process.platform === 'win32' ? encodedWindowsCommand(scriptPath) : posixSource(scriptPath);
|
||||||
? 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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kimiRuntimeReference(
|
export function kimiRuntimeReference(
|
||||||
@@ -142,18 +23,8 @@ export function kimiRuntimeReference(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
): KimiRuntimeReference | undefined {
|
): KimiRuntimeReference | undefined {
|
||||||
const parsed = parsePosixCommand(command)
|
const parsed = parsePosixCommand(command)
|
||||||
?? parseWindowsCommand(command)
|
?? parseEncodedWindowsCommand(command)
|
||||||
?? parseLegacyWindowsCommand(command);
|
?? parseLegacyWindowsCommand(command);
|
||||||
if (!parsed
|
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||||
|| !path.isAbsolute(parsed[0])
|
return managedScriptReference(parsed[1], scriptName);
|
||||||
|| !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] };
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { sameAgentId } from './common.js';
|
||||||
import {
|
import {
|
||||||
buildKimiCommand,
|
buildKimiCommand,
|
||||||
kimiRuntimeReference,
|
kimiRuntimeReference,
|
||||||
sameKimiAgentId,
|
|
||||||
type KimiRuntimeReference,
|
type KimiRuntimeReference,
|
||||||
} from './kimi-command.js';
|
} from './kimi-command.js';
|
||||||
|
import { isObject, type JsonObject } from './strict-json.js';
|
||||||
|
|
||||||
export const AGENT_KEY = 'kimi';
|
export const AGENT_KEY = 'kimi';
|
||||||
export const GUARD_SCRIPT = 'guard.js';
|
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 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 type ManagedKimiEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure';
|
||||||
|
|
||||||
export interface KimiHook extends TomlObject {
|
export interface KimiHook extends TomlObject {
|
||||||
@@ -75,14 +76,6 @@ export interface KimiRuntimeContract {
|
|||||||
readonly configPath: string;
|
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 {
|
function exactManagedKeys(hook: KimiHook): boolean {
|
||||||
return Object.keys(hook).sort().join('|') === 'command|event|timeout';
|
return Object.keys(hook).sort().join('|') === 'command|event|timeout';
|
||||||
}
|
}
|
||||||
@@ -194,7 +187,7 @@ async function renderHookSource(
|
|||||||
if (document.usesHookTables) return patchToml(document.raw ?? '', nextRoot);
|
if (document.usesHookTables) return patchToml(document.raw ?? '', nextRoot);
|
||||||
|
|
||||||
let base = document.raw ?? '';
|
let base = document.raw ?? '';
|
||||||
if (hasOwn(document.root, 'hooks')) {
|
if (Object.hasOwn(document.root, 'hooks')) {
|
||||||
const rootWithoutHooks = { ...document.root };
|
const rootWithoutHooks = { ...document.root };
|
||||||
delete rootWithoutHooks.hooks;
|
delete rootWithoutHooks.hooks;
|
||||||
base = patchToml(base, rootWithoutHooks);
|
base = patchToml(base, rootWithoutHooks);
|
||||||
@@ -248,7 +241,7 @@ export function removeManagedKimiHooks(
|
|||||||
if (!expected) return true;
|
if (!expected) return true;
|
||||||
const managedId = managedAgentId(hook, expected[0], expected[1]);
|
const managedId = managedAgentId(hook, expected[0], expected[1]);
|
||||||
if (!managedId) return true;
|
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';
|
const DISPLAY_NAME = 'Kimi';
|
||||||
|
|
||||||
export type KimiRuntimePaths = ManagedRuntimePaths;
|
|
||||||
export type PreparedKimiInstallation = PreparedManagedInstallation;
|
|
||||||
export type { RenameFile };
|
|
||||||
|
|
||||||
function hookLocations(documents: readonly KimiConfigDocument[]) {
|
function hookLocations(documents: readonly KimiConfigDocument[]) {
|
||||||
return documents.map((document) => ({
|
return documents.map((document) => ({
|
||||||
directoryLabel: document.contract.directoryLabel,
|
directoryLabel: document.contract.directoryLabel,
|
||||||
@@ -37,7 +33,7 @@ function hookLocations(documents: readonly KimiConfigDocument[]) {
|
|||||||
export async function preflightKimiInstallation(
|
export async function preflightKimiInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
documents: readonly KimiConfigDocument[],
|
documents: readonly KimiConfigDocument[],
|
||||||
): Promise<KimiRuntimePaths> {
|
): Promise<ManagedRuntimePaths> {
|
||||||
return preflightManagedInstallation({
|
return preflightManagedInstallation({
|
||||||
agentKey: AGENT_KEY,
|
agentKey: AGENT_KEY,
|
||||||
hookLocations: hookLocations(documents),
|
hookLocations: hookLocations(documents),
|
||||||
@@ -56,7 +52,7 @@ function renderedSource(rendered: RenderedKimiDocument): string {
|
|||||||
export async function prepareKimiInstallation(
|
export async function prepareKimiInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
rendered: readonly RenderedKimiDocument[],
|
rendered: readonly RenderedKimiDocument[],
|
||||||
): Promise<PreparedKimiInstallation> {
|
): Promise<PreparedManagedInstallation> {
|
||||||
return prepareManagedInstallation({
|
return prepareManagedInstallation({
|
||||||
agentKey: AGENT_KEY,
|
agentKey: AGENT_KEY,
|
||||||
displayName: DISPLAY_NAME,
|
displayName: DISPLAY_NAME,
|
||||||
@@ -73,7 +69,7 @@ export async function prepareKimiInstallation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function commitKimiInstallation(
|
export async function commitKimiInstallation(
|
||||||
prepared: PreparedKimiInstallation,
|
prepared: PreparedManagedInstallation,
|
||||||
renameFile?: RenameFile,
|
renameFile?: RenameFile,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await commitManagedInstallation(prepared, renameFile);
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
|||||||
+4
-88
@@ -1,6 +1,7 @@
|
|||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { asError, errorMessage, hasCode, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
LEGACY_EVENTS,
|
LEGACY_EVENTS,
|
||||||
@@ -10,23 +11,9 @@ import {
|
|||||||
type KimiConfigDocument,
|
type KimiConfigDocument,
|
||||||
type KimiContract,
|
type KimiContract,
|
||||||
type KimiRuntimeContract,
|
type KimiRuntimeContract,
|
||||||
type TomlObject,
|
|
||||||
} from './kimi-contract.js';
|
} from './kimi-contract.js';
|
||||||
import { sameKimiAgentId, sameKimiPath } from './kimi-command.js';
|
|
||||||
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
|
||||||
import { parseStrictJsonObject } from './strict-json.js';
|
import { managedRuntimeFilesExist } from './managed-runtime-status.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pathEntryExists(filePath: string, label: string): Promise<boolean> {
|
async function pathEntryExists(filePath: string, label: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +59,7 @@ export async function resolveKimiContracts(): Promise<KimiContract[]> {
|
|||||||
const legacyHome = path.join(home, '.kimi');
|
const legacyHome = path.join(home, '.kimi');
|
||||||
const stable = stableContract(path.join(stableHome, 'config.toml'));
|
const stable = stableContract(path.join(stableHome, 'config.toml'));
|
||||||
const legacy = legacyContract(path.join(legacyHome, '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
|
const stableDetected = explicitHome !== undefined
|
||||||
|| await pathEntryExists(stableHome, 'Kimi Code home');
|
|| await pathEntryExists(stableHome, 'Kimi Code home');
|
||||||
@@ -96,82 +83,11 @@ export async function readKimiDocuments(): Promise<KimiConfigDocument[]> {
|
|||||||
return documents;
|
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(
|
export async function kimiRuntimeFilesExist(
|
||||||
contracts: readonly KimiRuntimeContract[],
|
contracts: readonly KimiRuntimeContract[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const contract of contracts) {
|
for (const contract of contracts) {
|
||||||
if (await runtimeContractExists(contract)) return true;
|
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
+14
-112
@@ -1,10 +1,12 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { managedScriptReference } from './common.js';
|
||||||
interface ParsedArgument {
|
import {
|
||||||
readonly value: string;
|
isNodeExecutable,
|
||||||
readonly next: number;
|
parsePosixCommand,
|
||||||
}
|
parsePowerShellSource,
|
||||||
|
posixSource,
|
||||||
|
powerShellSource,
|
||||||
|
} from './shell-command.js';
|
||||||
|
|
||||||
export interface LettaRuntimeReference {
|
export interface LettaRuntimeReference {
|
||||||
readonly agentId: string;
|
readonly agentId: string;
|
||||||
@@ -12,88 +14,11 @@ export interface LettaRuntimeReference {
|
|||||||
readonly scriptPath: string;
|
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 {
|
export function buildLettaCommand(scriptPath: string): string {
|
||||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||||
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath);
|
||||||
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 legacyScriptPath(command: string): string | undefined {
|
function legacyScriptPath(command: string): string | undefined {
|
||||||
@@ -102,39 +27,16 @@ function legacyScriptPath(command: string): string | undefined {
|
|||||||
return scriptPath.includes('"') ? undefined : scriptPath;
|
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(
|
export function lettaRuntimeReference(
|
||||||
command: string,
|
command: string,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
): LettaRuntimeReference | undefined {
|
): LettaRuntimeReference | undefined {
|
||||||
const parsed = process.platform === 'win32'
|
const parsed = process.platform === 'win32'
|
||||||
? parsePowerShellCommand(command)
|
? parsePowerShellSource(command)
|
||||||
: parsePosixCommand(command);
|
: parsePosixCommand(command);
|
||||||
if (parsed
|
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||||
&& path.isAbsolute(parsed[0])
|
const reference = managedScriptReference(parsed[1], scriptName);
|
||||||
&& isNodeExecutable(parsed[0])) {
|
return reference ? { ...reference, executablePath: parsed[0] } : undefined;
|
||||||
return runtimeReference(parsed[1], scriptName, parsed[0]);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lettaLegacyRuntimeReference(
|
export function lettaLegacyRuntimeReference(
|
||||||
@@ -142,5 +44,5 @@ export function lettaLegacyRuntimeReference(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
): LettaRuntimeReference | undefined {
|
): LettaRuntimeReference | undefined {
|
||||||
const legacy = legacyScriptPath(command);
|
const legacy = legacyScriptPath(command);
|
||||||
return legacy ? runtimeReference(legacy, scriptName) : undefined;
|
return legacy ? managedScriptReference(legacy, scriptName) : undefined;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
applyEdits,
|
import { changeJsonc } from './jsonc-edit.js';
|
||||||
modify,
|
|
||||||
type FormattingOptions,
|
|
||||||
type JSONPath,
|
|
||||||
} from 'jsonc-parser';
|
|
||||||
import type { FileSnapshot } from './managed-files.js';
|
import type { FileSnapshot } from './managed-files.js';
|
||||||
import {
|
import {
|
||||||
MANAGED_EVENTS,
|
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 {
|
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
|
||||||
return parseLettaDocument({
|
return parseLettaDocument({
|
||||||
kind: document.kind,
|
kind: document.kind,
|
||||||
@@ -124,25 +98,25 @@ function removeManagedEntries(
|
|||||||
for (const removal of eventRemovals) {
|
for (const removal of eventRemovals) {
|
||||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||||
if (removal.removeGroup) {
|
if (removal.removeGroup) {
|
||||||
raw = change(raw, groupPath, undefined);
|
raw = changeJsonc(raw, groupPath, undefined);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||||
(left, right) => right - left,
|
(left, right) => right - left,
|
||||||
)) {
|
)) {
|
||||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eventRemovals.length > 0) {
|
if (eventRemovals.length > 0) {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (groupsForEvent(current.hooks, event).length === 0) {
|
if (groupsForEvent(current.hooks, event).length === 0) {
|
||||||
raw = change(raw, ['hooks', event], undefined);
|
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||||
raw = change(raw, ['hooks'], undefined);
|
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -156,9 +130,9 @@ function appendGroup(
|
|||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
const groups = groupsForEvent(current.hooks, event);
|
const groups = groupsForEvent(current.hooks, event);
|
||||||
if (groups.length > 0) {
|
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(
|
export function renderLettaDocument(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import { sameAgentId, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
buildLettaCommand,
|
buildLettaCommand,
|
||||||
lettaLegacyRuntimeReference,
|
lettaLegacyRuntimeReference,
|
||||||
lettaRuntimeReference,
|
lettaRuntimeReference,
|
||||||
sameLettaAgentId,
|
|
||||||
sameLettaPath,
|
|
||||||
type LettaRuntimeReference,
|
type LettaRuntimeReference,
|
||||||
} from './letta-command.js';
|
} from './letta-command.js';
|
||||||
import { isObject, type JsonObject } from './strict-json.js';
|
import { isObject, type JsonObject } from './strict-json.js';
|
||||||
@@ -220,7 +219,7 @@ export function managedLettaRemovals(
|
|||||||
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
||||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||||
const reference = managedReference(handler, scriptName);
|
const reference = managedReference(handler, scriptName);
|
||||||
return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId))
|
return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId))
|
||||||
? [handlerIndex]
|
? [handlerIndex]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
@@ -248,7 +247,7 @@ function referencesForEvent(
|
|||||||
const reference = exactCurrentReference(handler, scriptName);
|
const reference = exactCurrentReference(handler, scriptName);
|
||||||
if (!reference
|
if (!reference
|
||||||
|| !reference.executablePath
|
|| !reference.executablePath
|
||||||
|| !sameLettaPath(reference.executablePath, process.execPath)) continue;
|
|| !samePath(reference.executablePath, process.execPath)) continue;
|
||||||
const key = process.platform === 'win32'
|
const key = process.platform === 'win32'
|
||||||
? reference.agentId.toLowerCase()
|
? reference.agentId.toLowerCase()
|
||||||
: reference.agentId;
|
: reference.agentId;
|
||||||
@@ -270,7 +269,7 @@ export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[]
|
|||||||
const post = posts.get(key);
|
const post = posts.get(key);
|
||||||
const failure = failures.get(key);
|
const failure = failures.get(key);
|
||||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
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({
|
contracts.push({
|
||||||
agentId: guard[0].agentId,
|
agentId: guard[0].agentId,
|
||||||
guardPath: guard[0].scriptPath,
|
guardPath: guard[0].scriptPath,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
GUARD_SCRIPT,
|
GUARD_SCRIPT,
|
||||||
LETTA_AUDIT_OPTIONS,
|
LETTA_AUDIT_OPTIONS,
|
||||||
} from './letta-contract.js';
|
} from './letta-contract.js';
|
||||||
import { sameLettaPath } from './letta-command.js';
|
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
lettaDocumentLabel,
|
lettaDocumentLabel,
|
||||||
type RenderedLettaDocument,
|
type RenderedLettaDocument,
|
||||||
@@ -31,16 +31,11 @@ import {
|
|||||||
|
|
||||||
const DISPLAY_NAME = 'Letta Code';
|
const DISPLAY_NAME = 'Letta Code';
|
||||||
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
|
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(
|
export async function preflightLettaInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
sources: LettaSources,
|
sources: LettaSources,
|
||||||
): Promise<LettaRuntimePaths> {
|
): Promise<ManagedRuntimePaths> {
|
||||||
requireLettaHooksEnabled(sources);
|
requireLettaHooksEnabled(sources);
|
||||||
return preflightManagedInstallation({
|
return preflightManagedInstallation({
|
||||||
agentKey: AGENT_KEY,
|
agentKey: AGENT_KEY,
|
||||||
@@ -54,7 +49,7 @@ export async function preflightLettaInstallation(
|
|||||||
|
|
||||||
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
||||||
return sources.preconditions.filter((condition) => (
|
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,
|
config: InstallConfig,
|
||||||
sources: LettaSources,
|
sources: LettaSources,
|
||||||
rendered: RenderedLettaDocument,
|
rendered: RenderedLettaDocument,
|
||||||
): Promise<PreparedLettaInstallation> {
|
): Promise<PreparedManagedInstallation> {
|
||||||
await preflightLettaInstallation(config, sources);
|
await preflightLettaInstallation(config, sources);
|
||||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||||
const prepared = await prepareManagedInstallation({
|
const prepared = await prepareManagedInstallation({
|
||||||
@@ -89,7 +84,7 @@ export async function prepareLettaInstallation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function commitLettaInstallation(
|
export async function commitLettaInstallation(
|
||||||
prepared: PreparedLettaInstallation,
|
prepared: PreparedManagedInstallation,
|
||||||
renameFile?: RenameFile,
|
renameFile?: RenameFile,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await commitManagedInstallation(prepared, renameFile);
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import {
|
|||||||
type LettaDocument,
|
type LettaDocument,
|
||||||
type LettaDocumentKind,
|
type LettaDocumentKind,
|
||||||
} from './letta-config.js';
|
} from './letta-config.js';
|
||||||
|
import { hasCode, MAX_SOURCE_BYTES, pathKey, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
inspectPhysicalDirectory,
|
inspectPhysicalDirectory,
|
||||||
readPhysicalFile,
|
readPhysicalFile,
|
||||||
type FileSnapshot,
|
type FileSnapshot,
|
||||||
} from './managed-files.js';
|
} from './managed-files.js';
|
||||||
|
|
||||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
export interface LettaSourcePrecondition {
|
export interface LettaSourcePrecondition {
|
||||||
readonly filePath: string;
|
readonly filePath: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -39,15 +38,6 @@ export interface LettaSources {
|
|||||||
readonly preconditions: readonly LettaSourcePrecondition[];
|
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> {
|
async function canonicalPath(filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await fsp.realpath(filePath);
|
return await fsp.realpath(filePath);
|
||||||
@@ -93,7 +83,7 @@ function deduplicatePreconditions(
|
|||||||
): LettaSourcePrecondition[] {
|
): LettaSourcePrecondition[] {
|
||||||
const result = new Map<string, LettaSourcePrecondition>();
|
const result = new Map<string, LettaSourcePrecondition>();
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const key = comparisonPath(value.filePath);
|
const key = pathKey(value.filePath);
|
||||||
if (!result.has(key)) result.set(key, value);
|
if (!result.has(key)) result.set(key, value);
|
||||||
}
|
}
|
||||||
return [...result.values()];
|
return [...result.values()];
|
||||||
@@ -123,7 +113,7 @@ export async function readLettaSources(): Promise<LettaSources> {
|
|||||||
const projectDirectory = path.join(workspace, '.letta');
|
const projectDirectory = path.join(workspace, '.letta');
|
||||||
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
||||||
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration 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');
|
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
|
||||||
}
|
}
|
||||||
const globalPath = path.join(globalDirectory, 'settings.json');
|
const globalPath = path.join(globalDirectory, 'settings.json');
|
||||||
@@ -133,7 +123,7 @@ export async function readLettaSources(): Promise<LettaSources> {
|
|||||||
canonicalPath(workspace),
|
canonicalPath(workspace),
|
||||||
canonicalPath(homeDirectory),
|
canonicalPath(homeDirectory),
|
||||||
]);
|
]);
|
||||||
const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
const projectActive = !samePath(canonicalWorkspace, canonicalHome);
|
||||||
const global = await readDocument('global', globalPath);
|
const global = await readDocument('global', globalPath);
|
||||||
const project = projectActive
|
const project = projectActive
|
||||||
? await readDocument('project', projectPath)
|
? await readDocument('project', projectPath)
|
||||||
|
|||||||
+13
-106
@@ -1,103 +1,20 @@
|
|||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { managedScriptReference, type ManagedScriptReference } from './common.js';
|
||||||
|
import {
|
||||||
|
isNodeExecutable,
|
||||||
|
parsePosixCommand,
|
||||||
|
parsePowerShellSource,
|
||||||
|
posixSource,
|
||||||
|
powerShellSource,
|
||||||
|
} from './shell-command.js';
|
||||||
|
|
||||||
interface ParsedArgument {
|
export type QwenRuntimeReference = ManagedScriptReference;
|
||||||
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 function buildQwenCommand(scriptPath: string): string {
|
export function buildQwenCommand(scriptPath: string): string {
|
||||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||||
throw new Error('Qwen Code hook commands require absolute executable and script paths');
|
throw new Error('Qwen Code hook commands require absolute executable and script paths');
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
return process.platform === 'win32' ? powerShellSource(scriptPath) : posixSource(scriptPath);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function qwenRuntimeReference(
|
export function qwenRuntimeReference(
|
||||||
@@ -105,18 +22,8 @@ export function qwenRuntimeReference(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
): QwenRuntimeReference | undefined {
|
): QwenRuntimeReference | undefined {
|
||||||
const parsed = process.platform === 'win32'
|
const parsed = process.platform === 'win32'
|
||||||
? parsePowerShellCommand(command)
|
? parsePowerShellSource(command)
|
||||||
: parsePosixCommand(command);
|
: parsePosixCommand(command);
|
||||||
if (!parsed
|
if (!parsed || !path.isAbsolute(parsed[0]) || !isNodeExecutable(parsed[0])) return undefined;
|
||||||
|| !path.isAbsolute(parsed[0])
|
return managedScriptReference(parsed[1], scriptName);
|
||||||
|| !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] };
|
|
||||||
}
|
}
|
||||||
+10
-86
@@ -1,25 +1,15 @@
|
|||||||
import {
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
applyEdits,
|
|
||||||
modify,
|
|
||||||
parse,
|
|
||||||
parseTree,
|
|
||||||
printParseErrorCode,
|
|
||||||
type FormattingOptions,
|
|
||||||
type JSONPath,
|
|
||||||
type Node,
|
|
||||||
type ParseError,
|
|
||||||
} from 'jsonc-parser';
|
|
||||||
import type { FileSnapshot } from './managed-files.js';
|
import type { FileSnapshot } from './managed-files.js';
|
||||||
|
import { changeJsonc } from './jsonc-edit.js';
|
||||||
import {
|
import {
|
||||||
MANAGED_EVENTS,
|
MANAGED_EVENTS,
|
||||||
managedQwenRemovals,
|
managedQwenRemovals,
|
||||||
readQwenHooks,
|
readQwenHooks,
|
||||||
type JsonObject,
|
|
||||||
type ManagedQwenEvent,
|
type ManagedQwenEvent,
|
||||||
type QwenGroup,
|
type QwenGroup,
|
||||||
type QwenHooks,
|
type QwenHooks,
|
||||||
isObject,
|
|
||||||
} from './qwen-contract.js';
|
} from './qwen-contract.js';
|
||||||
|
import { isObject, parseCommentedJsonObject, type JsonObject } from './strict-json.js';
|
||||||
|
|
||||||
export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora';
|
export const QWEN_OWNED_FILE_MARKER = '// Managed by Elydora';
|
||||||
|
|
||||||
@@ -53,50 +43,6 @@ interface DocumentOptions {
|
|||||||
readonly snapshot?: FileSnapshot;
|
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 {
|
export function qwenSourceLabel(kind: QwenDocumentKind): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'system-defaults': return 'Qwen Code system defaults';
|
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 {
|
export function parseQwenDocument(options: DocumentOptions): QwenDocument {
|
||||||
const label = `${qwenSourceLabel(options.kind)} at ${options.filePath}`;
|
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') {
|
if (root.disableAllHooks !== undefined && typeof root.disableAllHooks !== 'boolean') {
|
||||||
throw new Error(`${label} field "disableAllHooks" must be a 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 {
|
function currentDocument(document: QwenDocument, raw: string): QwenDocument {
|
||||||
return parseQwenDocument({
|
return parseQwenDocument({
|
||||||
kind: document.kind,
|
kind: document.kind,
|
||||||
@@ -195,25 +119,25 @@ function removeManagedEntries(
|
|||||||
for (const removal of eventRemovals) {
|
for (const removal of eventRemovals) {
|
||||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||||
if (removal.removeGroup) {
|
if (removal.removeGroup) {
|
||||||
raw = change(raw, groupPath, undefined);
|
raw = changeJsonc(raw, groupPath, undefined);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||||
(left, right) => right - left,
|
(left, right) => right - left,
|
||||||
)) {
|
)) {
|
||||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
raw = changeJsonc(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (eventRemovals.length > 0) {
|
if (eventRemovals.length > 0) {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if ((current.hooks[event] ?? []).length === 0) {
|
if ((current.hooks[event] ?? []).length === 0) {
|
||||||
raw = change(raw, ['hooks', event], undefined);
|
raw = changeJsonc(raw, ['hooks', event], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||||
raw = change(raw, ['hooks'], undefined);
|
raw = changeJsonc(raw, ['hooks'], undefined);
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -226,9 +150,9 @@ function appendGroup(
|
|||||||
): string {
|
): string {
|
||||||
const current = currentDocument(document, raw);
|
const current = currentDocument(document, raw);
|
||||||
if (current.hooks[event]) {
|
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(
|
export function renderQwenDocument(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { sameAgentId, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
buildQwenCommand,
|
buildQwenCommand,
|
||||||
qwenRuntimeReference,
|
qwenRuntimeReference,
|
||||||
sameQwenAgentId,
|
|
||||||
sameQwenPath,
|
|
||||||
type QwenRuntimeReference,
|
type QwenRuntimeReference,
|
||||||
} from './qwen-command.js';
|
} from './qwen-command.js';
|
||||||
import { isObject, type JsonObject } from './strict-json.js';
|
import { isObject, type JsonObject } from './strict-json.js';
|
||||||
@@ -283,7 +282,7 @@ export function managedQwenRemovals(
|
|||||||
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
(hooks[event] ?? []).forEach((group, groupIndex) => {
|
||||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||||
const reference = managedReference(handler, scriptName, hookName, true);
|
const reference = managedReference(handler, scriptName, hookName, true);
|
||||||
return reference && (agentId === undefined || sameQwenAgentId(reference.agentId, agentId))
|
return reference && (agentId === undefined || sameAgentId(reference.agentId, agentId))
|
||||||
? [handlerIndex]
|
? [handlerIndex]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
@@ -334,7 +333,7 @@ export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
|
|||||||
const post = posts.get(key);
|
const post = posts.get(key);
|
||||||
const failure = failures.get(key);
|
const failure = failures.get(key);
|
||||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
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({
|
contracts.push({
|
||||||
agentId: guard[0].agentId,
|
agentId: guard[0].agentId,
|
||||||
guardPath: guard[0].scriptPath,
|
guardPath: guard[0].scriptPath,
|
||||||
@@ -343,5 +342,3 @@ export function qwenRuntimeContracts(hooks: QwenHooks): QwenRuntimeContract[] {
|
|||||||
}
|
}
|
||||||
return contracts;
|
return contracts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isObject, type JsonObject };
|
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
AUDIT_SCRIPT,
|
AUDIT_SCRIPT,
|
||||||
GUARD_SCRIPT,
|
GUARD_SCRIPT,
|
||||||
} from './qwen-contract.js';
|
} from './qwen-contract.js';
|
||||||
import { sameQwenPath } from './qwen-command.js';
|
import { MAX_SOURCE_BYTES, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
qwenDocumentLabel,
|
qwenDocumentLabel,
|
||||||
type RenderedQwenDocument,
|
type RenderedQwenDocument,
|
||||||
@@ -30,16 +30,11 @@ import {
|
|||||||
|
|
||||||
const DISPLAY_NAME = 'Qwen Code';
|
const DISPLAY_NAME = 'Qwen Code';
|
||||||
const SETTINGS_DIRECTORY_LABEL = 'Qwen Code user configuration directory';
|
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(
|
export async function preflightQwenInstallation(
|
||||||
config: InstallConfig,
|
config: InstallConfig,
|
||||||
sources: QwenSources,
|
sources: QwenSources,
|
||||||
): Promise<QwenRuntimePaths> {
|
): Promise<ManagedRuntimePaths> {
|
||||||
requireQwenHooksEnabled(sources);
|
requireQwenHooksEnabled(sources);
|
||||||
return preflightManagedInstallation({
|
return preflightManagedInstallation({
|
||||||
agentKey: AGENT_KEY,
|
agentKey: AGENT_KEY,
|
||||||
@@ -53,7 +48,7 @@ export async function preflightQwenInstallation(
|
|||||||
|
|
||||||
function readOnlyPreconditions(sources: QwenSources, changedPath?: string) {
|
function readOnlyPreconditions(sources: QwenSources, changedPath?: string) {
|
||||||
return sources.preconditions.filter((condition) => (
|
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,
|
config: InstallConfig,
|
||||||
sources: QwenSources,
|
sources: QwenSources,
|
||||||
rendered: RenderedQwenDocument,
|
rendered: RenderedQwenDocument,
|
||||||
): Promise<PreparedQwenInstallation> {
|
): Promise<PreparedManagedInstallation> {
|
||||||
await preflightQwenInstallation(config, sources);
|
await preflightQwenInstallation(config, sources);
|
||||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||||
const prepared = await prepareManagedInstallation({
|
const prepared = await prepareManagedInstallation({
|
||||||
@@ -88,7 +83,7 @@ export async function prepareQwenInstallation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function commitQwenInstallation(
|
export async function commitQwenInstallation(
|
||||||
prepared: PreparedQwenInstallation,
|
prepared: PreparedManagedInstallation,
|
||||||
renameFile?: RenameFile,
|
renameFile?: RenameFile,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await commitManagedInstallation(prepared, renameFile);
|
await commitManagedInstallation(prepared, renameFile);
|
||||||
|
|||||||
+3
-91
@@ -1,99 +1,11 @@
|
|||||||
import os from 'node:os';
|
import { AGENT_KEY, type QwenRuntimeContract } from './qwen-contract.js';
|
||||||
import path from 'node:path';
|
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function qwenRuntimeFilesExist(
|
export async function qwenRuntimeFilesExist(
|
||||||
contracts: readonly QwenRuntimeContract[],
|
contracts: readonly QwenRuntimeContract[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const contract of contracts) {
|
for (const contract of contracts) {
|
||||||
if (await runtimeContractExists(contract)) return true;
|
if (await managedRuntimeFilesExist(contract, AGENT_KEY)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,17 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import { parse as parseDotenv } from 'dotenv';
|
import { parse as parseDotenv } from 'dotenv';
|
||||||
|
import { MAX_SOURCE_BYTES, pathKey, samePath } from './common.js';
|
||||||
import {
|
import {
|
||||||
createQwenDocument,
|
createQwenDocument,
|
||||||
parseQwenDocument,
|
parseQwenDocument,
|
||||||
parseQwenJsoncObject,
|
|
||||||
qwenDocumentLabel,
|
qwenDocumentLabel,
|
||||||
qwenSourceLabel,
|
qwenSourceLabel,
|
||||||
type QwenDocument,
|
type QwenDocument,
|
||||||
type QwenDocumentKind,
|
type QwenDocumentKind,
|
||||||
} from './qwen-config.js';
|
} from './qwen-config.js';
|
||||||
import { readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
import { readPhysicalFile, type FileSnapshot } from './managed-files.js';
|
||||||
|
import { parseCommentedJsonObject } from './strict-json.js';
|
||||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
||||||
const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const;
|
const HOME_ENV_KEYS = ['QWEN_HOME', 'QWEN_RUNTIME_DIR'] as const;
|
||||||
|
|
||||||
export interface QwenSourcePrecondition {
|
export interface QwenSourcePrecondition {
|
||||||
@@ -94,7 +93,7 @@ async function resolveQwenRouting(): Promise<RoutingResult> {
|
|||||||
|
|
||||||
const readCandidate = async (filePath: string): Promise<void> => {
|
const readCandidate = async (filePath: string): Promise<void> => {
|
||||||
const resolved = path.resolve(filePath);
|
const resolved = path.resolve(filePath);
|
||||||
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
const key = pathKey(resolved);
|
||||||
if (visited.has(key)) return;
|
if (visited.has(key)) return;
|
||||||
visited.add(key);
|
visited.add(key);
|
||||||
const snapshot = await readPhysicalFile(resolved, 'Qwen Code home environment');
|
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 {
|
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));
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +196,7 @@ async function workspaceTrust(
|
|||||||
original: snapshot,
|
original: snapshot,
|
||||||
};
|
};
|
||||||
if (!snapshot) return { trusted: true, precondition };
|
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)) {
|
for (const [rulePath, level] of Object.entries(rules)) {
|
||||||
if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) {
|
if (!['TRUST_FOLDER', 'TRUST_PARENT', 'DO_NOT_TRUST'].includes(String(level))) {
|
||||||
throw new Error(`Qwen Code trusted folders has invalid trust level for "${rulePath}"`);
|
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)) {
|
for (const [rulePath, level] of Object.entries(rules)) {
|
||||||
if (level === 'DO_NOT_TRUST'
|
if (level === 'DO_NOT_TRUST'
|
||||||
&& comparisonPath(workspace) === comparisonPath(await canonicalPath(rulePath))) {
|
&& samePath(workspace, await canonicalPath(rulePath))) {
|
||||||
return { trusted: false, precondition };
|
return { trusted: false, precondition };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +250,7 @@ function deduplicatePreconditions(
|
|||||||
): QwenSourcePrecondition[] {
|
): QwenSourcePrecondition[] {
|
||||||
const result = new Map<string, QwenSourcePrecondition>();
|
const result = new Map<string, QwenSourcePrecondition>();
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const key = comparisonPath(value.filePath);
|
const key = pathKey(value.filePath);
|
||||||
if (!result.has(key)) result.set(key, value);
|
if (!result.has(key)) result.set(key, value);
|
||||||
}
|
}
|
||||||
return [...result.values()];
|
return [...result.values()];
|
||||||
@@ -276,7 +270,7 @@ export async function readQwenSources(): Promise<QwenSources> {
|
|||||||
canonicalPath(process.cwd()),
|
canonicalPath(process.cwd()),
|
||||||
canonicalPath(os.homedir()),
|
canonicalPath(os.homedir()),
|
||||||
]);
|
]);
|
||||||
const workspaceActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
const workspaceActive = !samePath(canonicalWorkspace, canonicalHome);
|
||||||
const workspace = workspaceActive
|
const workspace = workspaceActive
|
||||||
? await readDocument('workspace', workspacePath)
|
? await readDocument('workspace', workspacePath)
|
||||||
: createQwenDocument('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 {
|
export function parseStrictJsoncObject(raw: string, label: string): JsonObject {
|
||||||
return parseJsonObject(raw, label, {
|
return parseJsonObject(raw, label, {
|
||||||
allowTrailingComma: true,
|
allowTrailingComma: true,
|
||||||
|
|||||||
Reference in new issue
Block a user