fix(letta): harden official hook integration
This commit is contained in:
1 parent
69a75153f5
commit
53b08ec4a7
13 files changed
+1985
-146
No files matched your search
@@ -37,6 +37,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
||||
- Resolve Kiro IDE 1.0 hooks through the current workspace `.kiro/hooks/elydora-audit.json`. Register exact `PreToolUse` and `PostToolUse` command entries in the v1 JSON schema with `.*` matchers, ten-second timeouts, and enabled state. Preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Use the user-level `~/.kiro/hooks/elydora-audit.kiro.hook` source solely as exact Elydora legacy migration input. Commit the workspace hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require a physical workspace hook path, strict schemas, exact runtime sources, canonical private keys, matching runtime identity, and a complete enabled contract during status checks, then verify both entries in the Agent Hooks panel.
|
||||
- Resolve Kiro CLI v2 through the global `~/.kiro/agents/elydora-audit.json` custom agent and CLI 2.13.0+ v3 through `~/.kiro/hooks/elydora-audit.json`. Register exact lower-camel `preToolUse` and `postToolUse` v2 handlers with `*` matchers and ten-second millisecond timeouts; register exact PascalCase `PreToolUse` and `PostToolUse` v3 handlers in Kiro's v1 schema with `.*` matchers and ten-second timeouts. Preserve the complete native payload, propagate frozen or revoked state through exit code `2`, and use encoded PowerShell commands on Windows. Commit both hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject path and reserved-name collisions, migrate only exact Elydora command ownership, and require physical sources, strict schemas, canonical private keys, exact runtime sources, matching identity, and a common complete v2/v3 contract during status checks. Validate v2 with `kiro-cli agent validate --path` and activate v3 with `kiro-cli --v3`.
|
||||
- Resolve OpenCode 1.18.4 global plugins through `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`; require an absolute `XDG_CONFIG_HOME` and use the official `.js` or `.ts` discovery contract. Export exactly one named `ElydoraAuditPlugin` function. Implement `tool.execute.before` with official `{ tool, sessionID, callID }` input plus `{ args }` output and `tool.execute.after` with official `{ tool, sessionID, callID, args }` input plus `{ title, output, metadata }` output. Preserve both native arguments. Run the guard synchronously with a bounded timeout and propagate frozen or revoked state by throwing; await the audit runtime with a bounded timeout and report failures to stderr after tool completion. Embed the current absolute Node executable in the generated plugin. Commit the plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require physical paths, strict metadata, exact generated sources, canonical private keys, executable identity, matching runtime identity, and a clean migration state during status checks. Restart active OpenCode sessions after installation.
|
||||
- Resolve Letta Code 0.28.13 global hooks through `${HOME:-~}/.letta/settings.json`, matching Letta's `HOME || homedir()` behavior. Keep the active workspace `.letta/settings.json` and `.letta/settings.local.json` sources read-only, deduplicate the project source when the workspace is the home directory, and apply the official `hooks.disabled` precedence: an explicit global `false` enables hooks, global `true` disables them, then project and project-local `true` values disable them. Validate all eleven current events plus command and prompt handler shapes while preserving future event payloads. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with `*` matchers and ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and surface audit delivery failures through stderr, owner-only `error.log`, and exit code `1`; Letta post events retain their documented nonblocking semantics. Use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows and exact two-argument shell commands on Unix. Commit global settings, generated runtimes, runtime config, and private key through one rollback-capable transaction guarded by every consulted source snapshot. Migrate the legacy `node "<script>"` form through exact ownership checks, require physical sources, canonical private keys, exact runtime sources, executable identity, matching runtime identity, and an effective enabled state during status checks, then run `/hooks` and restart active sessions.
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ Kiro CLI installation commits the global v2 custom agent at `~/.kiro/agents/elyd
|
||||
|
||||
OpenCode 1.18.4 installation writes the named `ElydoraAuditPlugin` export to `${XDG_CONFIG_HOME:-~/.config}/opencode/plugins/elydora-audit.js`, matching the official global plugin loader's `.js` and `.ts` discovery contract. `XDG_CONFIG_HOME` must be absolute. The `tool.execute.before` hook synchronously evaluates the full native input and argument payload and throws on frozen or revoked state. The awaited `tool.execute.after` hook preserves the complete native input and output payload and reports audit runtime failures to stderr. The plugin, exact legacy `.mjs` migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Status requires physical paths, exact generated sources, the installed Node executable, canonical private keys, and matching runtime identity. Restart active OpenCode sessions after installation.
|
||||
|
||||
Letta Code 0.28.13 installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups to `${HOME:-~}/.letta/settings.json`, following the [official hook source and event contract](https://docs.letta.com/letta-agent/hooks). Workspace `.letta/settings.json` and `.letta/settings.local.json` remain read-only and participate in effective `hooks.disabled` evaluation. An explicit global `false` enables hooks; global `true`, project `true`, and project-local `true` disable them according to Letta's precedence. Managed command handlers use ten-second millisecond timeouts, the current absolute Node.js executable, PowerShell exit-code propagation on Windows, and complete native snake_case payloads. Frozen and revoked state propagates through exit code `2`; audit delivery failures remain visible through stderr, owner-only `error.log`, and exit code `1`, while Letta post events remain nonblocking. Global settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of all three settings sources. Status requires an effective enabled state, exact hook and executable identity, physical files, canonical private keys, and matching runtime identity. Run `/hooks` and restart active Letta Code sessions after installation.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly executablePath?: 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 {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
}
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||
if (!command.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(command, 2);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(command, executable.next + 1);
|
||||
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function legacyScriptPath(command: string): string | undefined {
|
||||
if (!command.startsWith('node "') || !command.endsWith('"')) return undefined;
|
||||
const scriptPath = command.slice(6, -1);
|
||||
return scriptPath.includes('"') ? undefined : scriptPath;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
function runtimeReference(
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
executablePath?: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!sameLettaPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, executablePath, scriptPath };
|
||||
}
|
||||
|
||||
export function lettaRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (parsed
|
||||
&& path.isAbsolute(parsed[0])
|
||||
&& isNodeExecutable(parsed[0])) {
|
||||
return runtimeReference(parsed[1], scriptName, parsed[0]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lettaLegacyRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const legacy = legacyScriptPath(command);
|
||||
return legacy ? runtimeReference(legacy, scriptName) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
type FormattingOptions,
|
||||
type JSONPath,
|
||||
} from 'jsonc-parser';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedLettaRemovals,
|
||||
readLettaHooks,
|
||||
type LettaGroup,
|
||||
type LettaHooks,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export type LettaDocumentKind = 'global' | 'project' | 'project-local';
|
||||
|
||||
export interface LettaDocument {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: LettaHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedLettaDocument {
|
||||
readonly document: LettaDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function lettaSourceLabel(kind: LettaDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'project': return 'Letta Code project settings';
|
||||
case 'project-local': return 'Letta Code project-local settings';
|
||||
default: return 'Letta Code global settings';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLettaDocument(options: DocumentOptions): LettaDocument {
|
||||
const label = `${lettaSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseStrictJsonObject(options.raw, label);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readLettaHooks(root.hooks),
|
||||
hasHooksContainer: Object.hasOwn(root, 'hooks'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLettaDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function change(
|
||||
raw: string,
|
||||
jsonPath: JSONPath,
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
|
||||
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function groupsForEvent(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const groups = hooks[event];
|
||||
return Array.isArray(groups) ? groups : [];
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: LettaDocument,
|
||||
initial: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let raw = initial;
|
||||
const removals = managedLettaRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = change(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if (groupsForEvent(current.hooks, event).length === 0) {
|
||||
raw = change(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = change(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: LettaDocument,
|
||||
raw: string,
|
||||
event: ManagedLettaEvent,
|
||||
group: LettaGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
const groups = groupsForEvent(current.hooks, event);
|
||||
if (groups.length > 0) {
|
||||
return change(raw, ['hooks', event, groups.length], group, true);
|
||||
}
|
||||
return change(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderLettaDocument(
|
||||
document: LettaDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedLettaEvent, LettaGroup>,
|
||||
): RenderedLettaDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0 && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function lettaDocumentLabel(document: LettaDocument): string {
|
||||
return lettaSourceLabel(document.kind);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
buildLettaCommand,
|
||||
lettaLegacyRuntimeReference,
|
||||
lettaRuntimeReference,
|
||||
sameLettaAgentId,
|
||||
sameLettaPath,
|
||||
type LettaRuntimeReference,
|
||||
} from './letta-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'letta';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const LETTA_AUDIT_OPTIONS = {
|
||||
nativePayload: true,
|
||||
failClosed: true,
|
||||
} as const;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const TOOL_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
]);
|
||||
|
||||
const SIMPLE_EVENTS = new Set([
|
||||
'UserPromptSubmit',
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
const KNOWN_EVENTS = new Set([...TOOL_EVENTS, ...SIMPLE_EVENTS]);
|
||||
|
||||
export type ManagedLettaEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface LettaHandler extends JsonObject {
|
||||
readonly type: 'command' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
readonly model?: string;
|
||||
readonly timeout?: number;
|
||||
readonly quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface LettaGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: LettaHandler[];
|
||||
}
|
||||
|
||||
export interface LettaHooks extends JsonObject {
|
||||
readonly disabled?: boolean;
|
||||
readonly [event: string]: LettaGroup[] | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedLettaRemoval {
|
||||
readonly event: ManagedLettaEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): LettaHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command' && value.type !== 'prompt') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
if (value.quiet !== undefined && typeof value.quiet !== 'boolean') {
|
||||
throw new Error(`${label} quiet must be a boolean`);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
} else {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
if (value.model !== undefined && typeof value.model !== 'string') {
|
||||
throw new Error(`${label} model must be a string`);
|
||||
}
|
||||
}
|
||||
return value as LettaHandler;
|
||||
}
|
||||
|
||||
function validateGroup(
|
||||
value: unknown,
|
||||
event: string,
|
||||
index: number,
|
||||
): LettaGroup {
|
||||
const label = `Letta Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (TOOL_EVENTS.has(event)) {
|
||||
if (typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
} else if (value.matcher !== undefined) {
|
||||
throw new Error(`${label} matcher is unsupported for ${event}`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as LettaGroup;
|
||||
}
|
||||
|
||||
export function readLettaHooks(value: unknown): LettaHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Letta Code settings field "hooks" must be an object');
|
||||
if (value.disabled !== undefined && typeof value.disabled !== 'boolean') {
|
||||
throw new Error('Letta Code settings field "hooks.disabled" must be a boolean');
|
||||
}
|
||||
const hooks: JsonObject = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (event === 'disabled') {
|
||||
hooks.disabled = groups;
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Letta Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks as LettaHooks;
|
||||
}
|
||||
|
||||
export function buildLettaGroup(scriptPath: string): LettaGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildLettaCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactGroup(group: LettaGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher'
|
||||
&& group.matcher === '*';
|
||||
}
|
||||
|
||||
function exactCurrentReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function exactLegacyReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|type'
|
||||
&& handler.type === 'command'
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaLegacyRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return exactCurrentReference(handler, scriptName)
|
||||
?? exactLegacyReference(handler, scriptName);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const;
|
||||
|
||||
function eventGroups(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const value = hooks[event];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function managedLettaRemovals(
|
||||
hooks: LettaHooks,
|
||||
agentId?: string,
|
||||
): ManagedLettaRemoval[] {
|
||||
const removals: ManagedLettaRemoval[] = [];
|
||||
for (const [event, scriptName] of EVENT_CONTRACTS) {
|
||||
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactGroup(group) && handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly LettaGroup[],
|
||||
scriptName: string,
|
||||
): Map<string, LettaRuntimeReference[]> {
|
||||
const references = new Map<string, LettaRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = exactCurrentReference(handler, scriptName);
|
||||
if (!reference
|
||||
|| !reference.executablePath
|
||||
|| !sameLettaPath(reference.executablePath, process.execPath)) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] {
|
||||
const guards = referencesForEvent(eventGroups(hooks, 'PreToolUse'), GUARD_SCRIPT);
|
||||
const posts = referencesForEvent(eventGroups(hooks, 'PostToolUse'), AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(
|
||||
eventGroups(hooks, 'PostToolUseFailure'),
|
||||
AUDIT_SCRIPT,
|
||||
);
|
||||
const contracts: LettaRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!sameLettaPath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
} from './letta-contract.js';
|
||||
import { sameLettaPath } from './letta-command.js';
|
||||
import {
|
||||
lettaDocumentLabel,
|
||||
type RenderedLettaDocument,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireLettaHooksEnabled,
|
||||
type LettaSources,
|
||||
} from './letta-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Letta Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export type LettaRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedLettaInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
): Promise<LettaRuntimePaths> {
|
||||
requireLettaHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.global.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !sameLettaPath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedLettaInstallation> {
|
||||
await preflightLettaInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaInstallation(
|
||||
prepared: PreparedLettaInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareLettaUninstall(
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createLettaDocument,
|
||||
lettaDocumentLabel,
|
||||
lettaSourceLabel,
|
||||
parseLettaDocument,
|
||||
type LettaDocument,
|
||||
type LettaDocumentKind,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export interface LettaSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface LettaDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: LettaDocument;
|
||||
}
|
||||
|
||||
export interface LettaSources {
|
||||
readonly homeDirectory: string;
|
||||
readonly global: LettaDocument;
|
||||
readonly project: LettaDocument;
|
||||
readonly projectLocal: LettaDocument;
|
||||
readonly projectActive: boolean;
|
||||
readonly disableControl: LettaDisableControl;
|
||||
readonly preconditions: readonly LettaSourcePrecondition[];
|
||||
}
|
||||
|
||||
function comparisonPath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return path.resolve(filePath);
|
||||
throw new Error(`Resolve Letta Code path at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
return path.resolve(process.env.HOME || os.homedir());
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<LettaDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, lettaSourceLabel(kind), MAX_SOURCE_BYTES);
|
||||
return snapshot
|
||||
? parseLettaDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createLettaDocument(kind, filePath);
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: LettaDocument): LettaSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: lettaDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly LettaSourcePrecondition[],
|
||||
): LettaSourcePrecondition[] {
|
||||
const result = new Map<string, LettaSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = comparisonPath(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
global: LettaDocument,
|
||||
project: LettaDocument,
|
||||
projectLocal: LettaDocument,
|
||||
projectActive: boolean,
|
||||
): LettaDisableControl {
|
||||
if (global.hooks.disabled === false) return { disabled: false, source: global };
|
||||
if (global.hooks.disabled === true) return { disabled: true, source: global };
|
||||
if (projectActive && project.hooks.disabled === true) {
|
||||
return { disabled: true, source: project };
|
||||
}
|
||||
if (projectLocal.hooks.disabled === true) {
|
||||
return { disabled: true, source: projectLocal };
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
export async function readLettaSources(): Promise<LettaSources> {
|
||||
const homeDirectory = resolveHomeDirectory();
|
||||
const workspace = process.cwd();
|
||||
const globalDirectory = path.join(homeDirectory, '.letta');
|
||||
const projectDirectory = path.join(workspace, '.letta');
|
||||
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
||||
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory');
|
||||
if (comparisonPath(projectDirectory) !== comparisonPath(globalDirectory)) {
|
||||
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
|
||||
}
|
||||
const globalPath = path.join(globalDirectory, 'settings.json');
|
||||
const projectPath = path.join(projectDirectory, 'settings.json');
|
||||
const projectLocalPath = path.join(projectDirectory, 'settings.local.json');
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(workspace),
|
||||
canonicalPath(homeDirectory),
|
||||
]);
|
||||
const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
||||
const global = await readDocument('global', globalPath);
|
||||
const project = projectActive
|
||||
? await readDocument('project', projectPath)
|
||||
: createLettaDocument('project', projectPath);
|
||||
const projectLocal = await readDocument('project-local', projectLocalPath);
|
||||
const preconditions = deduplicatePreconditions([
|
||||
sourcePrecondition(global),
|
||||
...(projectActive ? [sourcePrecondition(project)] : []),
|
||||
sourcePrecondition(projectLocal),
|
||||
]);
|
||||
return {
|
||||
homeDirectory,
|
||||
global,
|
||||
project,
|
||||
projectLocal,
|
||||
projectActive,
|
||||
disableControl: effectiveDisable(global, project, projectLocal, projectActive),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireLettaHooksEnabled(sources: LettaSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source
|
||||
? `${lettaDocumentLabel(source)} at ${source.filePath}`
|
||||
: 'effective settings';
|
||||
throw new Error(`Letta Code hooks are disabled by hooks.disabled in ${location}`);
|
||||
}
|
||||
+63
-146
@@ -1,173 +1,90 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
buildLettaGroup,
|
||||
lettaRuntimeContracts,
|
||||
type LettaGroup,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { renderLettaDocument } from './letta-config.js';
|
||||
import {
|
||||
commitLettaInstallation,
|
||||
commitLettaUninstall,
|
||||
preflightLettaInstallation,
|
||||
prepareLettaInstallation,
|
||||
prepareLettaUninstall,
|
||||
} from './letta-installation.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { readLettaSources } from './letta-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'letta';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedLettaEvent,
|
||||
LettaGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildLettaGroup(guardPath)],
|
||||
['PostToolUse', buildLettaGroup(auditPath)],
|
||||
['PostToolUseFailure', buildLettaGroup(auditPath)],
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
async function runtimeExists(
|
||||
contracts: ReturnType<typeof lettaRuntimeContracts>,
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
})) return true;
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export const lettaPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readLettaSources();
|
||||
await preflightLettaInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const paths = await preflightLettaInstallation(config, sources);
|
||||
const rendered = renderLettaDocument(
|
||||
sources.global,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitLettaInstallation(
|
||||
await prepareLettaInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Letta Code hooks: ${sources.global.filePath}`);
|
||||
console.log(' Letta Code verification: run /hooks and restart active sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const rendered = renderLettaDocument(sources.global, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitLettaUninstall(await prepareLettaUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readLettaSources();
|
||||
const contracts = lettaRuntimeContracts(sources.global.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeExists(contracts) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.global.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const VALID_PRIVATE_KEY = Buffer.alloc(32, 23).toString('base64url');
|
||||
export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta.js')).href;
|
||||
export const configModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-config.js')).href;
|
||||
export const contractModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-contract.js')).href;
|
||||
export const installationModuleUrl = pathToFileURL(
|
||||
path.resolve('dist/plugins/letta-installation.js'),
|
||||
).href;
|
||||
export const sourcesModuleUrl = pathToFileURL(path.resolve('dist/plugins/letta-sources.js')).href;
|
||||
export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
|
||||
export const cliPath = path.resolve('dist/cli.js');
|
||||
|
||||
export function runProcess(command, args, env, cwd, input = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childEnvironment = { ...process.env, ...env };
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (value === undefined) delete childEnvironment[name];
|
||||
}
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: childEnvironment,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, stdout, stderr }));
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
export function runNode(args, env, cwd, input = '') {
|
||||
return runProcess(process.execPath, args, env, cwd, input);
|
||||
}
|
||||
|
||||
export function environment(fixture) {
|
||||
return {
|
||||
HOME: fixture.homeDir,
|
||||
USERPROFILE: fixture.homeDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function installConfig(fixture, overrides = {}) {
|
||||
return {
|
||||
agentName: 'letta',
|
||||
orgId: 'org-1',
|
||||
agentId: fixture.agentId,
|
||||
privateKey: VALID_PRIVATE_KEY,
|
||||
kid: 'kid-1',
|
||||
token: 'token-1',
|
||||
baseUrl: fixture.baseUrl,
|
||||
guardScriptPath: fixture.guardScriptPath,
|
||||
hookScriptPath: fixture.hookScriptPath,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlugin(fixture, method, argument) {
|
||||
const source = `
|
||||
import { lettaPlugin } from ${JSON.stringify(pluginModuleUrl)};
|
||||
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
|
||||
const result = await lettaPlugin[process.env.ELYDORA_TEST_METHOD](argument);
|
||||
if (result !== undefined) console.log(JSON.stringify(result));
|
||||
`;
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', source],
|
||||
{
|
||||
...environment(fixture),
|
||||
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
|
||||
ELYDORA_TEST_METHOD: method,
|
||||
},
|
||||
fixture.projectDir,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeOptional(filePath, value) {
|
||||
if (value === undefined) return;
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const source = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
|
||||
await writeFile(filePath, source, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function createFixture({
|
||||
agentId = 'agent-1',
|
||||
baseUrl = 'http://127.0.0.1:9',
|
||||
globalSettings,
|
||||
projectSettings,
|
||||
localSettings,
|
||||
} = {}) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-letta-'));
|
||||
const homeDir = path.join(rootDir, "home with spaces and 'quote %HOME%");
|
||||
const projectDir = path.join(rootDir, 'workspace with spaces');
|
||||
const globalPath = path.join(homeDir, '.letta', 'settings.json');
|
||||
const projectPath = path.join(projectDir, '.letta', 'settings.json');
|
||||
const localPath = path.join(projectDir, '.letta', 'settings.local.json');
|
||||
const agentDir = path.join(homeDir, '.elydora', agentId);
|
||||
const guardScriptPath = path.join(agentDir, 'guard.js');
|
||||
const hookScriptPath = path.join(agentDir, 'hook.js');
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await writeOptional(globalPath, globalSettings);
|
||||
await writeOptional(projectPath, projectSettings);
|
||||
await writeOptional(localPath, localSettings);
|
||||
return {
|
||||
agentDir,
|
||||
agentId,
|
||||
baseUrl,
|
||||
globalPath,
|
||||
guardScriptPath,
|
||||
homeDir,
|
||||
hookScriptPath,
|
||||
localPath,
|
||||
projectDir,
|
||||
projectPath,
|
||||
rootDir,
|
||||
install(overrides = {}) {
|
||||
return runPlugin(this, 'install', installConfig(this, overrides));
|
||||
},
|
||||
async close() {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readSettings(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
export function managedHandler(settings, event) {
|
||||
return settings.hooks[event].find((group) => (
|
||||
group.matcher === '*'
|
||||
&& group.hooks.length === 1
|
||||
&& group.hooks[0].timeout === 10_000
|
||||
))?.hooks[0];
|
||||
}
|
||||
|
||||
export function runLettaHook(handler, input, fixture) {
|
||||
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
||||
const args = process.platform === 'win32'
|
||||
? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', handler.command]
|
||||
: ['-c', handler.command];
|
||||
return runProcess(shell, args, environment(fixture), fixture.projectDir, input);
|
||||
}
|
||||
|
||||
export async function startApiServer(initialStatus = 'active') {
|
||||
const requests = [];
|
||||
let status = initialStatus;
|
||||
let operationStatus = 201;
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
requests.push({ method: request.method, url: request.url, raw });
|
||||
if (request.method === 'GET') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ agent: { status } }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(operationStatus, { 'Content-Type': 'application/json' });
|
||||
response.end(operationStatus < 400
|
||||
? '{"operation":{"accepted":true}}'
|
||||
: '{"error":{"code":"AUDIT_UNAVAILABLE"}}');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
setStatus(value) { status = value; },
|
||||
setOperationStatus(value) { operationStatus = value; },
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
createFixture,
|
||||
environment,
|
||||
managedHandler,
|
||||
readSettings,
|
||||
runProcess,
|
||||
startApiServer,
|
||||
} from '../test-support/letta.mjs';
|
||||
|
||||
const sourceRoot = process.env.ELYDORA_LETTA_SOURCE;
|
||||
const bunBinary = process.env.ELYDORA_BUN_BINARY ?? 'bun';
|
||||
|
||||
function moduleUrl(relativePath) {
|
||||
return pathToFileURL(path.join(sourceRoot, relativePath)).href;
|
||||
}
|
||||
|
||||
function resultJson(stdout) {
|
||||
const line = stdout.trim().split(/\r?\n/).at(-1);
|
||||
return JSON.parse(line);
|
||||
}
|
||||
|
||||
async function runOfficialProbe(fixture, mode) {
|
||||
const settings = await readSettings(fixture.globalPath);
|
||||
const commands = {
|
||||
guard: managedHandler(settings, 'PreToolUse').command,
|
||||
audit: managedHandler(settings, 'PostToolUse').command,
|
||||
failure: managedHandler(settings, 'PostToolUseFailure').command,
|
||||
};
|
||||
const source = `
|
||||
import { settingsManager } from ${JSON.stringify(moduleUrl('src/settings-manager.ts'))};
|
||||
import { getHooksForEvent } from ${JSON.stringify(moduleUrl('src/hooks/loader.ts'))};
|
||||
import { executeCommandHook } from ${JSON.stringify(moduleUrl('src/hooks/executor.ts'))};
|
||||
import { runPostToolUseHooks } from ${JSON.stringify(moduleUrl('src/hooks/index.ts'))};
|
||||
const workspace = process.env.ELYDORA_OFFICIAL_WORKSPACE;
|
||||
const commands = JSON.parse(process.env.ELYDORA_OFFICIAL_COMMANDS);
|
||||
await settingsManager.initialize();
|
||||
await settingsManager.loadProjectSettings(workspace);
|
||||
await settingsManager.loadLocalProjectSettings(workspace);
|
||||
const pre = await getHooksForEvent('PreToolUse', 'Bash', workspace);
|
||||
const post = await getHooksForEvent('PostToolUse', 'Bash', workspace);
|
||||
const failure = await getHooksForEvent('PostToolUseFailure', 'Bash', workspace);
|
||||
const managed = (hooks, command) => hooks.find((hook) => (
|
||||
hook.type === 'command' && hook.command === command
|
||||
));
|
||||
if (process.env.ELYDORA_OFFICIAL_MODE === 'active') {
|
||||
const guardResult = await executeCommandHook(
|
||||
managed(pre, commands.guard),
|
||||
{
|
||||
event_type: 'PreToolUse',
|
||||
working_directory: workspace,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo official' },
|
||||
tool_call_id: 'official-pre',
|
||||
agent_id: 'letta-agent',
|
||||
},
|
||||
workspace,
|
||||
);
|
||||
const postResult = await executeCommandHook(
|
||||
managed(post, commands.audit),
|
||||
{
|
||||
event_type: 'PostToolUse',
|
||||
working_directory: workspace,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo official' },
|
||||
tool_result: { status: 'success', output: 'official' },
|
||||
tool_call_id: 'official-post',
|
||||
},
|
||||
workspace,
|
||||
);
|
||||
const failureResult = await executeCommandHook(
|
||||
managed(failure, commands.failure),
|
||||
{
|
||||
event_type: 'PostToolUseFailure',
|
||||
working_directory: workspace,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'exit 1' },
|
||||
error_message: 'official failure',
|
||||
error_type: 'ProcessError',
|
||||
tool_call_id: 'official-failure',
|
||||
},
|
||||
workspace,
|
||||
);
|
||||
console.log(JSON.stringify({
|
||||
counts: [pre.length, post.length, failure.length],
|
||||
exits: [guardResult.exitCode, postResult.exitCode, failureResult.exitCode],
|
||||
}));
|
||||
} else {
|
||||
const result = await runPostToolUseHooks(
|
||||
'Bash',
|
||||
{ command: 'echo unavailable' },
|
||||
{ status: 'success', output: 'unavailable' },
|
||||
'official-post-error',
|
||||
workspace,
|
||||
);
|
||||
console.log(JSON.stringify({
|
||||
blocked: result.blocked,
|
||||
errored: result.errored,
|
||||
exits: result.results.map((entry) => entry.exitCode),
|
||||
}));
|
||||
}
|
||||
await settingsManager.flush();
|
||||
`;
|
||||
return runProcess(
|
||||
bunBinary,
|
||||
['--silent', '--eval', source],
|
||||
{
|
||||
...environment(fixture),
|
||||
LETTA_SKIP_KEYCHAIN_CHECK: '1',
|
||||
ELYDORA_OFFICIAL_COMMANDS: JSON.stringify(commands),
|
||||
ELYDORA_OFFICIAL_MODE: mode,
|
||||
ELYDORA_OFFICIAL_WORKSPACE: fixture.projectDir,
|
||||
},
|
||||
sourceRoot,
|
||||
);
|
||||
}
|
||||
|
||||
test('official Letta Code 0.28.13 loads and executes all managed hooks', {
|
||||
skip: sourceRoot ? false : 'set ELYDORA_LETTA_SOURCE to the official Letta Code source tree',
|
||||
timeout: 60_000,
|
||||
}, async () => {
|
||||
const manifest = JSON.parse(await readFile(path.join(sourceRoot, 'package.json'), 'utf-8'));
|
||||
assert.equal(manifest.version, '0.28.13');
|
||||
const api = await startApiServer();
|
||||
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||
let apiClosed = false;
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const active = await runOfficialProbe(fixture, 'active');
|
||||
assert.equal(active.code, 0, active.stderr);
|
||||
assert.deepEqual(resultJson(active.stdout), {
|
||||
counts: [1, 1, 1],
|
||||
exits: [0, 0, 0],
|
||||
});
|
||||
assert.equal(api.requests.filter((request) => request.method === 'POST').length, 2);
|
||||
await api.close();
|
||||
apiClosed = true;
|
||||
const unavailable = await runOfficialProbe(fixture, 'unavailable');
|
||||
assert.equal(unavailable.code, 0, unavailable.stderr);
|
||||
assert.deepEqual(resultJson(unavailable.stdout), {
|
||||
blocked: false,
|
||||
errored: true,
|
||||
exits: [1],
|
||||
});
|
||||
} finally {
|
||||
await fixture.close();
|
||||
if (!apiClosed) await api.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { lstat, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
VALID_PRIVATE_KEY,
|
||||
cliPath,
|
||||
createFixture,
|
||||
environment,
|
||||
managedHandler,
|
||||
readSettings,
|
||||
registryModuleUrl,
|
||||
runNode,
|
||||
runPlugin,
|
||||
} from '../test-support/letta.mjs';
|
||||
|
||||
async function assertMissing(filePath) {
|
||||
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||
}
|
||||
|
||||
function assertManagedTriple(settings) {
|
||||
for (const event of ['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) {
|
||||
const group = settings.hooks[event].find((entry) => entry.hooks[0]?.timeout === 10_000);
|
||||
assert.deepEqual(Object.keys(group).sort(), ['hooks', 'matcher']);
|
||||
assert.equal(group.matcher, '*');
|
||||
assert.equal(group.hooks.length, 1);
|
||||
assert.deepEqual(Object.keys(group.hooks[0]).sort(), ['command', 'timeout', 'type']);
|
||||
assert.equal(group.hooks[0].type, 'command');
|
||||
assert.equal(group.hooks[0].timeout, 10_000);
|
||||
assert.doesNotMatch(group.hooks[0].command, /^node /);
|
||||
}
|
||||
assert.equal(
|
||||
managedHandler(settings, 'PostToolUse').command,
|
||||
managedHandler(settings, 'PostToolUseFailure').command,
|
||||
);
|
||||
}
|
||||
|
||||
test('Letta Code is registered in the SDK and CLI', async () => {
|
||||
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||
assert.deepEqual(SUPPORTED_AGENTS.get('letta'), {
|
||||
name: 'Letta Code',
|
||||
configDir: '~/.letta',
|
||||
configFile: 'settings.json',
|
||||
});
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
const result = await runNode(
|
||||
['--no-warnings', cliPath, 'status'],
|
||||
environment(fixture),
|
||||
fixture.projectDir,
|
||||
);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.match(result.stdout, /Letta Code \(letta\)/);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta installs the current three-event contract and preserves every source', async () => {
|
||||
const globalSettings = [
|
||||
'{',
|
||||
' "theme": "dark",',
|
||||
' "hooks": {',
|
||||
' "FutureEvent": [null],',
|
||||
' "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "user-hook", "quiet": true }] }]',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const projectSettings = { hooks: { PostToolUse: [{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'prompt', prompt: 'Review $ARGUMENTS', model: 'fast' }],
|
||||
}] } };
|
||||
const localSettings = { windowTitle: 'workspace' };
|
||||
const fixture = await createFixture({ globalSettings, projectSettings, localSettings });
|
||||
try {
|
||||
const projectBefore = await readFile(fixture.projectPath, 'utf-8');
|
||||
const localBefore = await readFile(fixture.localPath, 'utf-8');
|
||||
const first = await fixture.install();
|
||||
assert.equal(first.code, 0, first.stderr);
|
||||
assert.match(first.stdout, /run \/hooks/i);
|
||||
assert.match(first.stdout, /restart active sessions/i);
|
||||
const raw = await readFile(fixture.globalPath, 'utf-8');
|
||||
assert.match(raw, /\r\n/);
|
||||
const settings = JSON.parse(raw);
|
||||
assert.equal(settings.theme, 'dark');
|
||||
assert.deepEqual(settings.hooks.FutureEvent, [null]);
|
||||
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, 'user-hook');
|
||||
assertManagedTriple(settings);
|
||||
assert.equal(await readFile(fixture.projectPath, 'utf-8'), projectBefore);
|
||||
assert.equal(await readFile(fixture.localPath, 'utf-8'), localBefore);
|
||||
for (const filePath of [
|
||||
fixture.guardScriptPath,
|
||||
fixture.hookScriptPath,
|
||||
path.join(fixture.agentDir, 'config.json'),
|
||||
path.join(fixture.agentDir, 'private.key'),
|
||||
]) await lstat(filePath);
|
||||
const before = await readFile(fixture.globalPath, 'utf-8');
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
assert.equal(await readFile(fixture.globalPath, 'utf-8'), before);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta validates known hook schemas and preserves future events', async () => {
|
||||
const fixture = await createFixture({ globalSettings: {
|
||||
hooks: {
|
||||
FutureEvent: [null],
|
||||
PreToolUse: [{ matcher: '[', hooks: [{
|
||||
type: 'command',
|
||||
command: 'user-command',
|
||||
timeout: 0,
|
||||
quiet: false,
|
||||
futureField: true,
|
||||
}] }],
|
||||
Stop: [{ hooks: [{
|
||||
type: 'prompt',
|
||||
prompt: 'Evaluate $ARGUMENTS',
|
||||
model: 'fast',
|
||||
timeout: 30,
|
||||
}] }],
|
||||
},
|
||||
} });
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
const settings = await readSettings(fixture.globalPath);
|
||||
assert.deepEqual(settings.hooks.FutureEvent, [null]);
|
||||
assert.equal(settings.hooks.PreToolUse[0].matcher, '[');
|
||||
assert.equal(settings.hooks.PreToolUse[0].hooks[0].futureField, true);
|
||||
assert.equal(settings.hooks.Stop[0].hooks[0].type, 'prompt');
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta rejects malformed affected settings before runtime writes', async (t) => {
|
||||
const cases = [
|
||||
['syntax', '{ malformed', /parse Letta Code global settings/i],
|
||||
['root', '[]', /must contain a JSON object/i],
|
||||
['comment', '{ // comment\n "hooks": {} }', /parse Letta Code global settings/i],
|
||||
['trailing comma', '{ "theme": true, }', /parse Letta Code global settings/i],
|
||||
['duplicate', '{ "hooks": {}, "hooks": {} }', /duplicate field "hooks"/i],
|
||||
['hooks shape', '{ "hooks": [] }', /field "hooks" must be an object/i],
|
||||
['disabled', '{ "hooks": { "disabled": "yes" } }', /hooks.disabled.*boolean/i],
|
||||
['event shape', '{ "hooks": { "PreToolUse": null } }', /must be an array/i],
|
||||
['group shape', '{ "hooks": { "PreToolUse": [null] } }', /group.*object/i],
|
||||
['tool matcher', '{ "hooks": { "PreToolUse": [{ "hooks": [] }] } }', /matcher must be a string/i],
|
||||
['simple matcher', '{ "hooks": { "Stop": [{ "matcher": "*", "hooks": [] }] } }', /matcher is unsupported/i],
|
||||
['hooks missing', '{ "hooks": { "PreToolUse": [{ "matcher": "*" }] } }', /hooks array/i],
|
||||
['handler shape', '{ "hooks": { "Stop": [{ "hooks": [null] }] } }', /hooks\[0\].*object/i],
|
||||
['handler type', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "http" }] }] } }', /unsupported type/i],
|
||||
['command', '{ "hooks": { "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command" }] }] } }', /non-empty command/i],
|
||||
['prompt', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "prompt" }] }] } }', /non-empty prompt/i],
|
||||
['model', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "prompt", "prompt": "x", "model": 1 }] }] } }', /model must be a string/i],
|
||||
['quiet', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "x", "quiet": 1 }] }] } }', /quiet must be a boolean/i],
|
||||
['timeout', '{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "x", "timeout": -1 }] }] } }', /non-negative finite number/i],
|
||||
];
|
||||
for (const [name, source, pattern] of cases) {
|
||||
await t.test(name, async () => {
|
||||
const fixture = await createFixture({ globalSettings: source });
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, pattern);
|
||||
assert.equal(await readFile(fixture.globalPath, 'utf-8'), source);
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta surfaces malformed project sources before writes', async (t) => {
|
||||
for (const field of ['projectSettings', 'localSettings']) {
|
||||
await t.test(field, async () => {
|
||||
const fixture = await createFixture({ [field]: '{ malformed' });
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /parse Letta Code (project|project-local) settings/i);
|
||||
await assertMissing(fixture.globalPath);
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta applies the official hooks.disabled precedence', async (t) => {
|
||||
const cases = [
|
||||
['global true', { globalSettings: { hooks: { disabled: true } } }, 1],
|
||||
['project true', { projectSettings: { hooks: { disabled: true } } }, 1],
|
||||
['local true', { localSettings: { hooks: { disabled: true } } }, 1],
|
||||
['global false override', {
|
||||
globalSettings: { hooks: { disabled: false } },
|
||||
projectSettings: { hooks: { disabled: true } },
|
||||
localSettings: { hooks: { disabled: true } },
|
||||
}, 0],
|
||||
];
|
||||
for (const [name, options, expectedCode] of cases) {
|
||||
await t.test(name, async () => {
|
||||
const fixture = await createFixture(options);
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, expectedCode, result.stderr);
|
||||
if (expectedCode === 1) {
|
||||
assert.match(result.stderr, /hooks.disabled/i);
|
||||
await assertMissing(fixture.agentDir);
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta migrates exact legacy handlers and preserves ownership lookalikes', async () => {
|
||||
const fixture = await createFixture({ globalSettings: {} });
|
||||
const legacy = (scriptPath) => ({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: `node "${scriptPath}"` }],
|
||||
});
|
||||
const lookalike = legacy(fixture.guardScriptPath);
|
||||
lookalike.hooks[0].quiet = true;
|
||||
await writeFile(fixture.globalPath, `${JSON.stringify({ hooks: {
|
||||
PreToolUse: [legacy(fixture.guardScriptPath), lookalike],
|
||||
PostToolUse: [legacy(fixture.hookScriptPath)],
|
||||
} }, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
let settings = await readSettings(fixture.globalPath);
|
||||
assertManagedTriple(settings);
|
||||
assert(settings.hooks.PreToolUse.some((group) => group.hooks[0].quiet === true));
|
||||
const managed = settings.hooks.PreToolUse.find((group) => group.hooks[0].timeout === 10_000);
|
||||
managed.userField = 'preserve-group';
|
||||
managed.hooks.push({ type: 'command', command: 'user-command' });
|
||||
await writeFile(fixture.globalPath, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
|
||||
settings = await readSettings(fixture.globalPath);
|
||||
assert(settings.hooks.PreToolUse.some((group) => group.hooks[0].quiet === true));
|
||||
assert(settings.hooks.PreToolUse.some((group) => (
|
||||
group.hooks.some((handler) => handler.command === 'user-command')
|
||||
)));
|
||||
assert.equal(settings.hooks.PostToolUse, undefined);
|
||||
assert.equal(settings.hooks.PostToolUseFailure, undefined);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta status requires exact hooks and strict runtime identity', async () => {
|
||||
const fixture = await createFixture({ projectSettings: {} });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
let status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||
assert.equal(status.installed, true);
|
||||
await writeFile(fixture.projectPath, '{"hooks":{"disabled":true}}\n');
|
||||
status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||
assert.equal(status.hookConfigured, false);
|
||||
await writeFile(fixture.projectPath, '{}\n');
|
||||
status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||
assert.equal(status.installed, true);
|
||||
const settings = await readSettings(fixture.globalPath);
|
||||
settings.hooks.PostToolUseFailure.push(settings.hooks.PostToolUseFailure.at(-1));
|
||||
await writeFile(fixture.globalPath, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||
assert.equal(status.installed, false);
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
await writeFile(path.join(fixture.agentDir, 'private.key'), 'invalid');
|
||||
const invalid = await runPlugin(fixture, 'status', null);
|
||||
assert.equal(invalid.code, 1);
|
||||
assert.match(invalid.stderr, /private key is invalid/i);
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
await writeFile(fixture.hookScriptPath, 'tampered');
|
||||
status = JSON.parse((await runPlugin(fixture, 'status', null)).stdout);
|
||||
assert.equal(status.installed, false);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta uninstall removes a settings file containing only managed hooks', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
|
||||
await assertMissing(fixture.globalPath);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta CLI completes install, status, and uninstall end to end', async () => {
|
||||
const fixture = await createFixture({ globalSettings: { theme: 'dark' } });
|
||||
const privateKeyFile = path.join(fixture.rootDir, 'install-private.key');
|
||||
const tokenFile = path.join(fixture.rootDir, 'install-token.txt');
|
||||
try {
|
||||
await writeFile(privateKeyFile, `${VALID_PRIVATE_KEY}\n`, { mode: 0o600 });
|
||||
await writeFile(tokenFile, 'token-1\n', { mode: 0o600 });
|
||||
const install = await runNode([
|
||||
'--no-warnings', cliPath, 'install',
|
||||
'--agent', 'letta',
|
||||
'--org_id', 'org-1',
|
||||
'--agent_id', 'agent-1',
|
||||
'--kid', 'kid-1',
|
||||
'--private_key_file', privateKeyFile,
|
||||
'--token_file', tokenFile,
|
||||
'--base_url', fixture.baseUrl,
|
||||
], environment(fixture), fixture.projectDir);
|
||||
assert.equal(install.code, 0, install.stderr);
|
||||
assertManagedTriple(await readSettings(fixture.globalPath));
|
||||
const status = await runNode(
|
||||
['--no-warnings', cliPath, 'status'],
|
||||
environment(fixture),
|
||||
fixture.projectDir,
|
||||
);
|
||||
assert.equal(status.code, 0, status.stderr);
|
||||
assert.match(status.stdout, /Letta Code \(letta\) \[installed\]/);
|
||||
const uninstall = await runNode([
|
||||
'--no-warnings', cliPath, 'uninstall',
|
||||
'--agent', 'letta',
|
||||
'--agent_id', 'agent-1',
|
||||
], environment(fixture), fixture.projectDir);
|
||||
assert.equal(uninstall.code, 0, uninstall.stderr);
|
||||
assert.deepEqual(await readSettings(fixture.globalPath), { theme: 'dark' });
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createFixture,
|
||||
managedHandler,
|
||||
readSettings,
|
||||
runLettaHook,
|
||||
startApiServer,
|
||||
} from '../test-support/letta.mjs';
|
||||
|
||||
function officialInput(fixture, event) {
|
||||
const base = {
|
||||
event_type: event,
|
||||
working_directory: fixture.projectDir,
|
||||
session_id: 'session-1',
|
||||
};
|
||||
if (event === 'PreToolUse') {
|
||||
return {
|
||||
...base,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo test' },
|
||||
tool_call_id: 'call-1',
|
||||
agent_id: 'letta-agent-1',
|
||||
};
|
||||
}
|
||||
if (event === 'PostToolUse') {
|
||||
return {
|
||||
...base,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'echo test' },
|
||||
tool_call_id: 'call-1',
|
||||
tool_result: { status: 'success', output: 'test' },
|
||||
agent_id: 'letta-agent-1',
|
||||
preceding_reasoning: 'Run the command.',
|
||||
preceding_assistant_message: 'Executing.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'exit 1' },
|
||||
tool_call_id: 'call-2',
|
||||
error_message: 'Command failed',
|
||||
error_type: 'ProcessError',
|
||||
agent_id: 'letta-agent-1',
|
||||
preceding_reasoning: 'Run the command.',
|
||||
preceding_assistant_message: 'Executing.',
|
||||
};
|
||||
}
|
||||
|
||||
async function installedHandlers(fixture) {
|
||||
const settings = await readSettings(fixture.globalPath);
|
||||
return {
|
||||
guard: managedHandler(settings, 'PreToolUse'),
|
||||
audit: managedHandler(settings, 'PostToolUse'),
|
||||
failure: managedHandler(settings, 'PostToolUseFailure'),
|
||||
};
|
||||
}
|
||||
|
||||
test('Letta guard accepts active agents with native exit semantics', async () => {
|
||||
const api = await startApiServer('active');
|
||||
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { guard } = await installedHandlers(fixture);
|
||||
const result = await runLettaHook(
|
||||
guard,
|
||||
JSON.stringify(officialInput(fixture, 'PreToolUse')),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(api.requests.filter((request) => request.method === 'GET').length, 1);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta guard propagates frozen and revoked states through exit code 2', async (t) => {
|
||||
for (const status of ['frozen', 'revoked']) {
|
||||
await t.test(status, async () => {
|
||||
const api = await startApiServer(status);
|
||||
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { guard } = await installedHandlers(fixture);
|
||||
const result = await runLettaHook(
|
||||
guard,
|
||||
JSON.stringify(officialInput(fixture, 'PreToolUse')),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(result.code, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, new RegExp(`is ${status}`, 'i'));
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta audit forwards complete success and failure payloads', async () => {
|
||||
const api = await startApiServer();
|
||||
const fixture = await createFixture({ baseUrl: api.baseUrl });
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { audit, failure } = await installedHandlers(fixture);
|
||||
for (const [handler, event] of [
|
||||
[audit, 'PostToolUse'],
|
||||
[failure, 'PostToolUseFailure'],
|
||||
]) {
|
||||
const payload = {
|
||||
...officialInput(fixture, event),
|
||||
future_provider_field: { preserved: true },
|
||||
};
|
||||
const result = await runLettaHook(handler, JSON.stringify(payload), fixture);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
}
|
||||
const posts = api.requests.filter((request) => request.method === 'POST');
|
||||
assert.equal(posts.length, 2);
|
||||
assert.deepEqual(JSON.parse(posts[0].raw).payload, {
|
||||
...officialInput(fixture, 'PostToolUse'),
|
||||
future_provider_field: { preserved: true },
|
||||
});
|
||||
assert.deepEqual(JSON.parse(posts[1].raw).payload, {
|
||||
...officialInput(fixture, 'PostToolUseFailure'),
|
||||
future_provider_field: { preserved: true },
|
||||
});
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await api.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta audit failures emit stderr, persist error.log, and exit 1', async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
const { audit } = await installedHandlers(fixture);
|
||||
const result = await runLettaHook(
|
||||
audit,
|
||||
JSON.stringify(officialInput(fixture, 'PostToolUse')),
|
||||
fixture,
|
||||
);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /Elydora audit|fetch failed|ECONNREFUSED/i);
|
||||
assert.match(
|
||||
await readFile(path.join(fixture.agentDir, 'error.log'), 'utf-8'),
|
||||
/Elydora audit|fetch failed|ECONNREFUSED/i,
|
||||
);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta runtime artifacts use private modes on POSIX', {
|
||||
skip: process.platform === 'win32' ? 'POSIX mode bits are not authoritative on Windows' : false,
|
||||
}, async () => {
|
||||
const fixture = await createFixture();
|
||||
try {
|
||||
assert.equal((await fixture.install()).code, 0);
|
||||
for (const [filePath, expected] of [
|
||||
[path.join(fixture.agentDir, 'config.json'), 0o600],
|
||||
[path.join(fixture.agentDir, 'private.key'), 0o600],
|
||||
[fixture.guardScriptPath, 0o700],
|
||||
[fixture.hookScriptPath, 0o700],
|
||||
[fixture.globalPath, 0o600],
|
||||
]) {
|
||||
assert.equal((await stat(filePath)).mode & 0o777, expected);
|
||||
}
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { lstat, mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
configModuleUrl,
|
||||
contractModuleUrl,
|
||||
createFixture,
|
||||
environment,
|
||||
installConfig,
|
||||
installationModuleUrl,
|
||||
runNode,
|
||||
sourcesModuleUrl,
|
||||
} from '../test-support/letta.mjs';
|
||||
|
||||
async function assertMissing(filePath) {
|
||||
await assert.rejects(lstat(filePath), { code: 'ENOENT' });
|
||||
}
|
||||
|
||||
async function assertNoTransactionFiles(...directories) {
|
||||
const names = [];
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
names.push(...await readdir(directory));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
|
||||
}
|
||||
|
||||
async function runPreparedInstallation(fixture, {
|
||||
failSettings = false,
|
||||
racePath,
|
||||
raceSource,
|
||||
} = {}) {
|
||||
const source = `
|
||||
import fsp from 'node:fs/promises';
|
||||
import { buildLettaGroup } from ${JSON.stringify(contractModuleUrl)};
|
||||
import { renderLettaDocument } from ${JSON.stringify(configModuleUrl)};
|
||||
import { readLettaSources } from ${JSON.stringify(sourcesModuleUrl)};
|
||||
import {
|
||||
commitLettaInstallation,
|
||||
preflightLettaInstallation,
|
||||
prepareLettaInstallation,
|
||||
} from ${JSON.stringify(installationModuleUrl)};
|
||||
|
||||
const config = JSON.parse(process.env.ELYDORA_TEST_CONFIG);
|
||||
const sources = await readLettaSources();
|
||||
const paths = await preflightLettaInstallation(config, sources);
|
||||
const rendered = renderLettaDocument(sources.global, undefined, new Map([
|
||||
['PreToolUse', buildLettaGroup(paths.guardPath)],
|
||||
['PostToolUse', buildLettaGroup(paths.auditPath)],
|
||||
['PostToolUseFailure', buildLettaGroup(paths.auditPath)],
|
||||
]));
|
||||
const prepared = await prepareLettaInstallation(config, sources, rendered);
|
||||
if (process.env.ELYDORA_TEST_RACE_PATH) {
|
||||
await fsp.writeFile(
|
||||
process.env.ELYDORA_TEST_RACE_PATH,
|
||||
process.env.ELYDORA_TEST_RACE_SOURCE,
|
||||
);
|
||||
}
|
||||
await commitLettaInstallation(prepared, async (from, to) => {
|
||||
if (process.env.ELYDORA_TEST_FAIL_SETTINGS === 'true'
|
||||
&& to === sources.global.filePath) {
|
||||
throw new Error('injected Letta settings rename failure');
|
||||
}
|
||||
await fsp.rename(from, to);
|
||||
});
|
||||
`;
|
||||
return runNode(
|
||||
['--input-type=module', '--eval', source],
|
||||
{
|
||||
...environment(fixture),
|
||||
ELYDORA_TEST_CONFIG: JSON.stringify(installConfig(fixture)),
|
||||
ELYDORA_TEST_FAIL_SETTINGS: String(failSettings),
|
||||
ELYDORA_TEST_RACE_PATH: racePath,
|
||||
ELYDORA_TEST_RACE_SOURCE: raceSource,
|
||||
},
|
||||
fixture.projectDir,
|
||||
);
|
||||
}
|
||||
|
||||
test('Letta rolls back every runtime file when the settings commit fails', async () => {
|
||||
const original = '{"theme":"dark"}\n';
|
||||
const fixture = await createFixture({ globalSettings: original });
|
||||
try {
|
||||
const result = await runPreparedInstallation(fixture, { failSettings: true });
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /injected Letta settings rename failure/i);
|
||||
assert.equal(await readFile(fixture.globalPath, 'utf-8'), original);
|
||||
for (const filePath of [
|
||||
fixture.guardScriptPath,
|
||||
fixture.hookScriptPath,
|
||||
path.join(fixture.agentDir, 'config.json'),
|
||||
path.join(fixture.agentDir, 'private.key'),
|
||||
]) await assertMissing(filePath);
|
||||
await assertNoTransactionFiles(path.dirname(fixture.globalPath), fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta aborts when a read-only project source changes after prepare', async () => {
|
||||
const original = '{"windowTitle":"before"}\n';
|
||||
const raceSource = '{"windowTitle":"after"}\n';
|
||||
const fixture = await createFixture({ projectSettings: original });
|
||||
try {
|
||||
const result = await runPreparedInstallation(fixture, {
|
||||
racePath: fixture.projectPath,
|
||||
raceSource,
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /project settings changed during Letta Code installation/i);
|
||||
assert.equal(await readFile(fixture.projectPath, 'utf-8'), raceSource);
|
||||
await assertMissing(fixture.globalPath);
|
||||
for (const filePath of [
|
||||
fixture.guardScriptPath,
|
||||
fixture.hookScriptPath,
|
||||
path.join(fixture.agentDir, 'config.json'),
|
||||
path.join(fixture.agentDir, 'private.key'),
|
||||
]) await assertMissing(filePath);
|
||||
await assertNoTransactionFiles(path.dirname(fixture.globalPath), fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta rejects linked configuration directories before writes', {
|
||||
skip: process.platform === 'win32' ? 'directory symlinks require platform privileges on Windows' : false,
|
||||
}, async () => {
|
||||
const fixture = await createFixture();
|
||||
const target = path.join(fixture.rootDir, 'redirected-letta');
|
||||
await mkdir(target);
|
||||
await symlink(target, path.dirname(fixture.globalPath), 'dir');
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /global configuration directory is not a physical directory/i);
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Letta rejects a settings directory at the settings file path', async () => {
|
||||
const fixture = await createFixture();
|
||||
await mkdir(fixture.globalPath, { recursive: true });
|
||||
try {
|
||||
const result = await fixture.install();
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /global settings path is not a physical file/i);
|
||||
await assertMissing(fixture.agentDir);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user