From 384f3053c021093e4401c5551b4735b258afcf6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 09:33:49 -0400 Subject: [PATCH] fix(node): mirror transactional Auggie hooks --- AGENTS.md | 4 +- sdks/node/README.md | 2 +- sdks/node/src/plugins/augment-contract.ts | 35 +- sdks/node/src/plugins/augment-installation.ts | 85 ++++ sdks/node/src/plugins/augment-io.ts | 289 ++++++----- sdks/node/src/plugins/augment.ts | 46 +- sdks/node/src/plugins/managed-installation.ts | 27 ++ .../test-support/augment-test-helpers.mjs | 183 +++++++ sdks/node/test/augment-official.test.mjs | 44 ++ sdks/node/test/augment-plugin.test.mjs | 454 +++++------------- sdks/node/test/augment-runtime.test.mjs | 154 ++++++ sdks/node/test/augment-transaction.test.mjs | 306 ++++++++++++ 12 files changed, 1122 insertions(+), 507 deletions(-) create mode 100644 sdks/node/src/plugins/augment-installation.ts create mode 100644 sdks/node/test-support/augment-test-helpers.mjs create mode 100644 sdks/node/test/augment-official.test.mjs create mode 100644 sdks/node/test/augment-runtime.test.mjs create mode 100644 sdks/node/test/augment-transaction.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 245ca6e..3e024c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,8 @@ This repository publishes the open-source server, console, integration catalog, - Resolve Grok Build user hooks through `$GROK_HOME/hooks/*.json` with `~/.grok/hooks/*.json` as the default, and treat an empty override as the default. Keep Claude Code, Cursor, project `.grok/hooks`, plugin, and `hooks-paths` sources read-only. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second command handlers, preserve the complete native camelCase payload, and emit Grok's documented deny JSON plus exit code `2` for frozen or revoked agents. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit each SDK's Grok hook file, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires the exact event triple, strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts. - Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit each SDK's Claude Code user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects. - Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit each SDK's Gemini CLI user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects. -- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. -- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. +- Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. +- The Node SDK commits Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Node status requires exact runtime identity, canonical private keys, physical files, and exact wrapper sources, followed by effective configuration verification with `auggie tools list`. Python and Go validate matcher syntax with their native regex engines and keep status and uninstall available for recovery. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. diff --git a/sdks/node/README.md b/sdks/node/README.md index 5909a37..0bf4754 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -71,7 +71,7 @@ Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `P Qwen Code installation writes user hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). User-level `.qwen/.env` takes precedence over `~/.env` when it defines `QWEN_HOME`; explicit process environment values take highest precedence. Workspace settings remain unchanged. Run `/hooks` to review the definitions. `disableAllHooks` and `--safe-mode` suspend hook execution. -Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory. +Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration. Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. diff --git a/sdks/node/src/plugins/augment-contract.ts b/sdks/node/src/plugins/augment-contract.ts index c8147f0..0a2efe5 100644 --- a/sdks/node/src/plugins/augment-contract.ts +++ b/sdks/node/src/plugins/augment-contract.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import path from 'node:path'; +import { parseStrictJsonObject } from './strict-json.js'; export const AGENT_KEY = 'augment'; export const GUARD_SCRIPT = 'guard.js'; @@ -40,6 +41,13 @@ export interface AugmentDocument { readonly configPath: string; readonly root: JsonObject; readonly hooks: AugmentHooks; + readonly raw?: string; +} + +export interface RenderedAugmentDocument { + readonly document: AugmentDocument; + readonly changed: boolean; + readonly next?: string; } export interface RuntimeContract { @@ -126,11 +134,10 @@ function sameAgentId(left: string, right: string): boolean { return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; } -export function wrapperPaths(agentId: string): { +export function wrapperPaths(agentDirectory: string): { readonly guardPath: string; readonly auditPath: string; } { - const agentDirectory = path.join(os.homedir(), '.elydora', agentId); return { guardPath: path.join(agentDirectory, GUARD_WRAPPER), auditPath: path.join(agentDirectory, AUDIT_WRAPPER), @@ -242,6 +249,30 @@ export function readHooks(root: JsonObject): AugmentHooks { return hooks; } +export function parseAugmentDocument(configPath: string, raw: string): AugmentDocument { + const root = parseStrictJsonObject(raw, `Auggie user settings at ${configPath}`); + return { exists: true, configPath, root, hooks: readHooks(root), raw }; +} + +export function createAugmentDocument(configPath: string): AugmentDocument { + return { exists: false, configPath, root: {}, hooks: {} }; +} + +export function renderAugmentDocument( + document: AugmentDocument, + hooks: AugmentHooks, +): RenderedAugmentDocument { + if (!document.exists && Object.keys(hooks).length === 0) { + return { document, changed: false }; + } + const root: JsonObject = { ...document.root }; + if (Object.keys(hooks).length > 0) root.hooks = hooks; + else delete root.hooks; + if (Object.keys(root).length === 0) return { document, changed: true }; + const next = `${JSON.stringify(root, null, 2)}\n`; + return { document, changed: next !== document.raw, next }; +} + function removeManaged( groups: AugmentGroup[], wrapperName: string, diff --git a/sdks/node/src/plugins/augment-installation.ts b/sdks/node/src/plugins/augment-installation.ts new file mode 100644 index 0000000..341bd94 --- /dev/null +++ b/sdks/node/src/plugins/augment-installation.ts @@ -0,0 +1,85 @@ +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + AUDIT_WRAPPER, + GUARD_SCRIPT, + GUARD_WRAPPER, + buildWrapper, + type RenderedAugmentDocument, +} from './augment-contract.js'; +import { + commitManagedInstallation, + managedRuntimePaths, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; + +const DISPLAY_NAME = 'Augment Code CLI'; +const SETTINGS_DIRECTORY_LABEL = 'Auggie configuration directory'; +const SETTINGS_LABEL = 'Auggie user settings'; + +export type AugmentRuntimePaths = ManagedRuntimePaths; +export type PreparedAugmentInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +export function augmentRuntimePaths(config: InstallConfig): AugmentRuntimePaths { + return managedRuntimePaths(config, AGENT_KEY, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function preflightAugmentInstallation( + config: InstallConfig, + settingsPath: string, +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: [{ directoryLabel: SETTINGS_DIRECTORY_LABEL, filePath: settingsPath }], + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareAugmentInstallation( + config: InstallConfig, + rendered: RenderedAugmentDocument, +): Promise { + if (!rendered.changed && rendered.document.raw === undefined) { + throw new Error('Auggie hook installation did not produce a settings document'); + } + const settingsSource = rendered.next ?? rendered.document.raw; + if (settingsSource === undefined) throw new Error('Auggie hook settings are missing'); + const paths = augmentRuntimePaths(config); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: SETTINGS_DIRECTORY_LABEL, + label: SETTINGS_LABEL, + filePath: rendered.document.configPath, + expectedSource: rendered.document.raw, + source: settingsSource, + }], + runtimeFiles: [{ + fileName: GUARD_WRAPPER, + label: 'Auggie guard wrapper', + source: buildWrapper(paths.guardPath), + mode: 0o700, + }, { + fileName: AUDIT_WRAPPER, + label: 'Auggie audit wrapper', + source: buildWrapper(paths.auditPath), + mode: 0o700, + }], + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function commitAugmentInstallation( + prepared: PreparedAugmentInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} diff --git a/sdks/node/src/plugins/augment-io.ts b/sdks/node/src/plugins/augment-io.ts index d1af151..ead189f 100644 --- a/sdks/node/src/plugins/augment-io.ts +++ b/sdks/node/src/plugins/augment-io.ts @@ -1,182 +1,167 @@ -import { randomUUID } from 'node:crypto'; -import fsp from 'node:fs/promises'; -import type { FileHandle } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { AGENT_KEY, + AUDIT_SCRIPT, + AUDIT_WRAPPER, + GUARD_SCRIPT, + GUARD_WRAPPER, + buildWrapper, + createAugmentDocument, + parseAugmentDocument, type AugmentDocument, - type JsonObject, + type RenderedAugmentDocument, type RuntimeContract, - isObject, - readHooks, } from './augment-contract.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, +} from './managed-transaction.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return isObject(error) && error.code === code; -} +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; export function resolveConfigPath(): string { return path.join(os.homedir(), '.augment', 'settings.json'); } -async function readJsonObject(filePath: string, label: string): Promise { - let raw: string; - try { - raw = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return undefined; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - let value: unknown; - try { - value = JSON.parse(raw); - } catch (error) { - throw new Error(`Failed to parse ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - if (!isObject(value)) throw new Error(`${label} at ${filePath} must contain a JSON object`); - return value; -} - export async function readConfig(): Promise { const configPath = resolveConfigPath(); - const root = await readJsonObject(configPath, 'Auggie settings'); - if (!root) return { exists: false, configPath, root: {}, hooks: {} }; - return { exists: true, configPath, root, hooks: readHooks(root) }; + await inspectPhysicalDirectory(path.dirname(configPath), 'Auggie configuration directory'); + const snapshot = await readPhysicalFile(configPath, 'Auggie user settings', MAX_CONFIG_BYTES); + return snapshot + ? parseAugmentDocument(configPath, snapshot.contents) + : createAugmentDocument(configPath); } -async function failWrite( - handle: FileHandle | undefined, - tempPath: string, - label: string, - cause: unknown, -): Promise { - const errors = [asError(cause)]; - if (handle) { - try { - await handle.close(); - } catch (error) { - errors.push(asError(error)); - } - } - try { - await fsp.unlink(tempPath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) errors.push(asError(error)); - } - const message = `Write ${label}: ${errorMessage(cause)}`; - if (errors.length > 1) throw new AggregateError(errors, message); - throw new Error(message, { cause: errors[0] }); -} - -async function writeAtomic( - filePath: string, - contents: string, - mode: number, - label: string, -): Promise { - const directory = path.dirname(filePath); - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - const tempPath = path.join(directory, `.${path.basename(filePath)}.${randomUUID()}.tmp`); - let handle: FileHandle | undefined; - try { - handle = await fsp.open(tempPath, 'wx', mode); - await handle.writeFile(contents, 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - await fsp.rename(tempPath, filePath); - } catch (error) { - await failWrite(handle, tempPath, `${label} at ${filePath}`, error); - } -} - -export async function writeConfig(configPath: string, root: JsonObject): Promise { - await writeAtomic( - configPath, - JSON.stringify(root, null, 2) + '\n', - 0o600, - 'Auggie settings', - ); -} - -export async function writeWrapper(wrapperPath: string, contents: string): Promise { - await writeAtomic(wrapperPath, contents, 0o700, 'Auggie hook wrapper'); -} - -export async function removeConfig(configPath: string): Promise { - try { - await fsp.unlink(configPath); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return; - throw new Error(`Remove Auggie settings at ${configPath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } -} - -export async function regularFileExists(filePath: string, label: string): Promise { - try { - return (await fsp.stat(filePath)).isFile(); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } -} - -export async function requireRuntime(filePath: string, label: string): Promise { - if (!filePath) throw new Error(`${label} path is required`); - if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); +export async function writeAugmentDocument(rendered: RenderedAugmentDocument): Promise { + if (!rendered.changed) return; + const change = await prepareManagedFileChange({ + filePath: rendered.document.configPath, + label: 'Auggie user settings', + next: rendered.next, + mode: 0o600, + maximumBytes: MAX_CONFIG_BYTES, + expectedSource: rendered.document.raw, + verifyExpectedSource: true, + }); + if (!change) return; + await commitManagedTransaction({ + displayName: 'Augment Code CLI', + operation: 'uninstall', + directories: [{ + path: path.dirname(rendered.document.configPath), + label: 'Auggie configuration directory', + }], + changes: [change], + }); } function sameAgentId(left: string, right: string): boolean { - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; + return process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; } -export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise { - const root = path.join(os.homedir(), '.elydora'); - let entries: Array<{ isDirectory(): boolean; name: string }>; +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === 'win32' + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function requireNonEmptyString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); + } + return value; +} + +function validateRuntimeConfig( + config: JsonObject, + contract: RuntimeContract, + configPath: string, +): void { + const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); + const extra = Object.keys(config).find((key) => !supported.has(key)); + if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); + requireNonEmptyString(config.org_id, 'org_id', configPath); + requireNonEmptyString(config.kid, 'kid', configPath); + const agentId = requireNonEmptyString(config.agent_id, 'agent_id', configPath); + if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Auggie hooks: ${configPath}`); + } + if (config.token !== undefined) requireNonEmptyString(config.token, 'token', configPath); + const rawBaseUrl = requireNonEmptyString(config.base_url, 'base_url', configPath); + let baseUrl: URL; try { - entries = await fsp.readdir(root, { withFileTypes: true }); + baseUrl = new URL(rawBaseUrl); } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return false; - throw new Error(`Read Elydora runtime directory at ${root}: ${errorMessage(error)}`, { - cause: asError(error), + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { + cause: error instanceof Error ? error : new Error(String(error)), }); } + if (!['http:', 'https:'].includes(baseUrl.protocol) + || !baseUrl.hostname + || baseUrl.username + || baseUrl.password + || baseUrl.search + || baseUrl.hash) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); + } +} + +function validatePrivateKey(contents: string, keyPath: string): void { + const bytes = Buffer.from(contents, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { + throw new Error(`Elydora private key is invalid: ${keyPath}`); + } +} + +function validContractPaths(contract: RuntimeContract, agentDirectory: string): boolean { + return samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora')) + && samePath(contract.guardPath, path.join(agentDirectory, GUARD_SCRIPT)) + && samePath(contract.auditPath, path.join(agentDirectory, AUDIT_SCRIPT)) + && samePath(contract.guardWrapperPath, path.join(agentDirectory, GUARD_WRAPPER)) + && samePath(contract.auditWrapperPath, path.join(agentDirectory, AUDIT_WRAPPER)); +} + +async function runtimeContractExists(contract: RuntimeContract): Promise { + const runtimeRoot = path.join(os.homedir(), '.elydora'); + const agentDirectory = path.dirname(contract.guardPath); + if (!validContractPaths(contract, agentDirectory)) return false; + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; + if (!await inspectPhysicalDirectory(agentDirectory, 'Elydora agent runtime directory')) { + return false; + } + const configPath = path.join(agentDirectory, 'config.json'); + const keyPath = path.join(agentDirectory, 'private.key'); + const [config, key, guard, audit, guardWrapper, auditWrapper] = await Promise.all([ + readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), + readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), + readPhysicalFile(contract.guardWrapperPath, 'Auggie guard wrapper'), + readPhysicalFile(contract.auditWrapperPath, 'Auggie audit wrapper'), + ]); + if (!config || !key || !guard || !audit || !guardWrapper || !auditWrapper) return false; + const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); + validateRuntimeConfig(parsed, contract, configPath); + validatePrivateKey(key.contents, keyPath); + return guard.contents.length > 0 + && audit.contents.length > 0 + && guardWrapper.contents === buildWrapper(contract.guardPath) + && auditWrapper.contents === buildWrapper(contract.auditPath); +} + +export async function augmentRuntimeFilesExist( + contracts: readonly RuntimeContract[], +): Promise { for (const contract of contracts) { - const directory = entries.find( - (item) => item.isDirectory() && sameAgentId(item.name, contract.agentId), - ); - if (!directory) continue; - const agentDirectory = path.join(root, directory.name); - const runtimeConfigPath = path.join(agentDirectory, 'config.json'); - const runtimeConfig = await readJsonObject(runtimeConfigPath, 'Elydora runtime config'); - if (!runtimeConfig || runtimeConfig.agent_name !== AGENT_KEY) continue; - const files = [ - [contract.guardPath, 'Elydora guard runtime'], - [contract.auditPath, 'Elydora audit runtime'], - [contract.guardWrapperPath, 'Auggie guard wrapper'], - [contract.auditWrapperPath, 'Auggie audit wrapper'], - ] as const; - const existence = await Promise.all( - files.map(([filePath, label]) => regularFileExists(filePath, label)), - ); - if (existence.every(Boolean)) return true; + if (await runtimeContractExists(contract)) return true; } return false; } diff --git a/sdks/node/src/plugins/augment.ts b/sdks/node/src/plugins/augment.ts index 7b44174..9ee65f6 100644 --- a/sdks/node/src/plugins/augment.ts +++ b/sdks/node/src/plugins/augment.ts @@ -3,32 +3,36 @@ import { SUPPORTED_AGENTS } from './registry.js'; import { AGENT_KEY, buildHandler, - buildWrapper, removeManagedHooks, + renderAugmentDocument, runtimeContracts, wrapperPaths, } from './augment-contract.js'; import { + commitAugmentInstallation, + preflightAugmentInstallation, + prepareAugmentInstallation, +} from './augment-installation.js'; +import { + augmentRuntimeFilesExist, readConfig, - removeConfig, - requireRuntime, - runtimeFilesExist, - writeConfig, - writeWrapper, + writeAugmentDocument, } from './augment-io.js'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; export const augmentPlugin: AgentPlugin = { - async install(config: InstallConfig): Promise { - if (!config.agentId) throw new Error('agentId is required'); - const document = await readConfig(); - await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); - await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); + managesRuntime: true, - const wrappers = wrapperPaths(config.agentId); - await writeWrapper(wrappers.guardPath, buildWrapper(config.guardScriptPath)); - await writeWrapper(wrappers.auditPath, buildWrapper(config.hookScriptPath)); + async preflightInstall(config: InstallConfig): Promise { + const document = await readConfig(); + await preflightAugmentInstallation(config, document.configPath); + }, + + async install(config: InstallConfig): Promise { + const document = await readConfig(); + const paths = await preflightAugmentInstallation(config, document.configPath); + const wrappers = wrapperPaths(paths.agentDirectory); const cleaned = removeManagedHooks(document.hooks).hooks; const hooks = { @@ -42,7 +46,9 @@ export const augmentPlugin: AgentPlugin = { { matcher: '.*', hooks: [buildHandler(wrappers.auditPath)] }, ], }; - await writeConfig(document.configPath, { ...document.root, hooks }); + const rendered = renderAugmentDocument(document, hooks); + const prepared = await prepareAugmentInstallation(config, rendered); + await commitAugmentInstallation(prepared); console.log(' Auggie: user-level PreToolUse and PostToolUse hooks installed.'); }, @@ -51,18 +57,16 @@ export const augmentPlugin: AgentPlugin = { if (!document.exists) return; const result = removeManagedHooks(document.hooks, agentId); if (!result.changed) return; - const root = { ...document.root }; - if (Object.keys(result.hooks).length > 0) root.hooks = result.hooks; - else delete root.hooks; - if (Object.keys(root).length === 0) await removeConfig(document.configPath); - else await writeConfig(document.configPath, root); + await writeAugmentDocument(renderAugmentDocument(document, result.hooks)); }, async status(): Promise { const document = await readConfig(); const contracts = runtimeContracts(document.hooks); const hookConfigured = contracts.length > 0; - const hookScriptExists = hookConfigured ? await runtimeFilesExist(contracts) : false; + const hookScriptExists = hookConfigured + ? await augmentRuntimeFilesExist(contracts) + : false; return { installed: hookConfigured && hookScriptExists, agentName: AGENT_KEY, diff --git a/sdks/node/src/plugins/managed-installation.ts b/sdks/node/src/plugins/managed-installation.ts index e44cfdc..f4bbad3 100644 --- a/sdks/node/src/plugins/managed-installation.ts +++ b/sdks/node/src/plugins/managed-installation.ts @@ -38,10 +38,19 @@ export interface ManagedHookSource extends ManagedHookLocation { readonly source: string; } +export interface ManagedRuntimeFile { + readonly fileName: string; + readonly label: string; + readonly source: string; + readonly mode: number; + readonly maximumBytes?: number; +} + export interface ManagedInstallationSpec { readonly agentKey: string; readonly displayName: string; readonly hookSources: readonly ManagedHookSource[]; + readonly runtimeFiles?: readonly ManagedRuntimeFile[]; readonly config: InstallConfig; readonly guardOptions?: GuardScriptOptions; readonly auditOptions?: HookScriptOptions; @@ -202,6 +211,17 @@ function hookDirectories(sources: readonly ManagedHookSource[]): ManagedDirector })); } +function runtimeFilePath(paths: ManagedRuntimePaths, fileName: string): string { + if (!fileName + || fileName === '.' + || fileName === '..' + || fileName.includes('/') + || fileName.includes('\\')) { + throw new Error(`Managed runtime fileName must be a basename: ${fileName}`); + } + return path.join(paths.agentDirectory, fileName); +} + export async function prepareManagedInstallation( spec: ManagedInstallationSpec, guardScript: string, @@ -240,6 +260,13 @@ export async function prepareManagedInstallation( next: generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions), mode: 0o700, }), + ...(spec.runtimeFiles ?? []).map((runtimeFile) => prepareManagedFileChange({ + filePath: runtimeFilePath(paths, runtimeFile.fileName), + label: runtimeFile.label, + next: runtimeFile.source, + mode: runtimeFile.mode, + maximumBytes: runtimeFile.maximumBytes, + })), ...spec.hookSources.map((source) => prepareManagedFileChange({ filePath: source.filePath, label: source.label, diff --git a/sdks/node/test-support/augment-test-helpers.mjs b/sdks/node/test-support/augment-test-helpers.mjs new file mode 100644 index 0000000..123d657 --- /dev/null +++ b/sdks/node/test-support/augment-test-helpers.mjs @@ -0,0 +1,183 @@ +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).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment.js')).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/augment-installation.js'), +).href; +export const managedInstallationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/managed-installation.js'), +).href; +export const cliPath = path.resolve('dist/cli.js'); +export const wrapperExtension = process.platform === 'win32' ? '.cmd' : '.sh'; +export const guardWrapperName = `augment-guard${wrapperExtension}`; +export const auditWrapperName = `augment-hook${wrapperExtension}`; + +export function runProcess(command, args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + 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 runHandler(handler, input, fixture) { + return new Promise((resolve, reject) => { + const child = spawn(handler.command, { + cwd: fixture.projectDir, + env: { ...process.env, HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }, + shell: true, + 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 async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const contents = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`; + await writeFile(filePath, contents, { encoding: 'utf-8', mode: 0o600 }); +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'augment', + orgId: 'org-1', + agentId: 'agent-1', + 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 { augmentPlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await augmentPlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +export async function createFixture({ baseUrl = 'http://127.0.0.1:9', settings } = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %AUGGIE%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const settingsPath = path.join(homeDir, '.augment', 'settings.json'); + const agentDir = path.join(homeDir, '.elydora', 'agent-1'); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + if (settings !== undefined) await writeJson(settingsPath, settings); + const fixture = { + agentDir, + auditWrapperPath: path.join(agentDir, auditWrapperName), + baseUrl, + guardScriptPath, + guardWrapperPath: path.join(agentDir, guardWrapperName), + homeDir, + hookScriptPath, + projectDir, + rootDir, + settingsPath, + install(overrides = {}) { + return runPlugin(this, 'install', installConfig(this, overrides)); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export async function readSettings(filePath) { + const raw = await readFile(filePath, 'utf-8'); + return { raw, settings: JSON.parse(raw) }; +} + +export function generatedCommand(wrapperPath) { + if (process.platform === 'win32') return `"${wrapperPath.replaceAll('"', '\\"')}"`; + return `'${wrapperPath.replaceAll("'", `'"'"'`)}'`; +} + +export function managedHandler(settings, event, wrapperPath) { + const command = generatedCommand(wrapperPath); + for (const group of settings.hooks?.[event] ?? []) { + const handler = group.hooks.find((candidate) => candidate.command === command); + if (handler) return handler; + } + return undefined; +} + +export async function startApiServer({ status = 'active', operationStatus = 201 } = {}) { + const requests = []; + 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({ headers: request.headers, 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 >= 200 && operationStatus < 300 + ? JSON.stringify({ operation: { accepted: true } }) + : JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: 'failed' } })); + }); + 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, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/sdks/node/test/augment-official.test.mjs b/sdks/node/test/augment-official.test.mjs new file mode 100644 index 0000000..4040633 --- /dev/null +++ b/sdks/node/test/augment-official.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createFixture, + runProcess, +} from '../test-support/augment-test-helpers.mjs'; + +const auggieEntry = process.env.ELYDORA_AUGGIE_ENTRY; + +test('official Auggie accepts the installed user hook contract', { + skip: auggieEntry ? false : 'set ELYDORA_AUGGIE_ENTRY to the official Auggie entry file', +}, async () => { + const fixture = await createFixture(); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const environment = { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + }; + const version = await runProcess( + process.execPath, + [auggieEntry, '--version'], + environment, + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /0\.33\.0/); + + const load = await runProcess( + process.execPath, + [auggieEntry, 'tools', 'list'], + environment, + fixture.projectDir, + ); + assert.equal(load.code, 0, load.stderr); + assert.doesNotMatch( + `${load.stdout}\n${load.stderr}`, + /invalid settings|settings validation|failed to parse|hook configuration error/i, + ); + } finally { + await fixture.close(); + } +}); diff --git a/sdks/node/test/augment-plugin.test.mjs b/sdks/node/test/augment-plugin.test.mjs index f07f3f4..ca2b56f 100644 --- a/sdks/node/test/augment-plugin.test.mjs +++ b/sdks/node/test/augment-plugin.test.mjs @@ -1,193 +1,87 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { lstat, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { + VALID_PRIVATE_KEY, + auditWrapperName, + cliPath, + createFixture, + generatedCommand, + guardWrapperName, + installConfig, + managedHandler, + pluginModuleUrl, + readSettings, + registryModuleUrl, + runNode, + runPlugin, +} from '../test-support/augment-test-helpers.mjs'; -const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/augment.js')).href; -const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -const cliPath = path.resolve('dist/cli.js'); -const wrapperExtension = process.platform === 'win32' ? '.cmd' : '.sh'; -const guardWrapperName = `augment-guard${wrapperExtension}`; -const auditWrapperName = `augment-hook${wrapperExtension}`; - -function runNode(args, env, input = '', cwd) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - cwd, - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - 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); - }); +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); } -function runCommand(command, input = '') { - return new Promise((resolve, reject) => { - const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); - 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); - }); -} - -function quotePosix(value) { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function quoteWindows(value) { - return `"${value.replaceAll('"', '\\"')}"`; -} - -function generatedCommand(wrapperPath) { - return process.platform === 'win32' ? quoteWindows(wrapperPath) : quotePosix(wrapperPath); -} - -async function runPlugin(fixture, method, argument) { - const script = ` - import { augmentPlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await augmentPlugin[process.env.ELYDORA_TEST_METHOD](argument); - if (result !== undefined) console.log(JSON.stringify(result)); - `; - return runNode( - ['--input-type=module', '--eval', script], - { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - '', - fixture.workspaceDir, - ); -} - -async function createFixture({ existingSettings, guardSource, hookSource } = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote"); - const workspaceDir = path.join(homeDir, 'workspace'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const configPath = path.join(homeDir, '.augment', 'settings.json'); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - const guardWrapperPath = path.join(agentDir, guardWrapperName); - const auditWrapperPath = path.join(agentDir, auditWrapperName); - await mkdir(workspaceDir, { recursive: true }); - await mkdir(agentDir, { recursive: true }); - await writeFile( - guardScriptPath, - guardSource ?? "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n", - ); - await writeFile(hookScriptPath, hookSource ?? 'process.exit(0);\n'); - await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ - agent_id: 'agent-1', - agent_name: 'augment', - })); - if (existingSettings !== undefined) { - await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, typeof existingSettings === 'string' - ? existingSettings - : JSON.stringify(existingSettings, null, 2)); - } - const fixture = { - agentDir, - auditWrapperPath, - configPath, - guardScriptPath, - guardWrapperPath, - homeDir, - hookScriptPath, - workspaceDir, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, - }; - fixture.installResult = await runPlugin(fixture, 'install', { - agentName: 'augment', - agentId: 'agent-1', - baseUrl: 'https://api.elydora.com', - guardScriptPath, - hookScriptPath, - }); - return fixture; -} - -function managedHandler(settings, event, wrapperPath) { - const command = generatedCommand(wrapperPath); - for (const group of settings.hooks?.[event] ?? []) { - const handler = group.hooks.find((candidate) => candidate.command === command); - if (handler) return handler; - } - return undefined; -} - -test('Augment Code CLI is registered in the SDK and CLI', async () => { +test('Augment Code CLI is registered and owns its complete runtime installation', async () => { const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + const { augmentPlugin } = await import(pluginModuleUrl); assert.deepEqual(SUPPORTED_AGENTS.get('augment'), { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json', }); - const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-cli-')); - const workspaceDir = path.join(homeDir, 'workspace'); - await mkdir(workspaceDir); + assert.equal(augmentPlugin.managesRuntime, true); + const fixture = await createFixture(); try { const result = await runNode(['--no-warnings', cliPath, 'status'], { - HOME: homeDir, - USERPROFILE: homeDir, - }, '', workspaceDir); + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + }, fixture.projectDir); assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /Augment Code CLI \(augment\)/); } finally { - await rm(homeDir, { recursive: true, force: true }); + await fixture.close(); } }); -test('Auggie install preserves user settings and is idempotent', async () => { +test('Auggie installation preserves official settings and is idempotent', async () => { const existing = { telemetryEnabled: false, hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'existing-command', args: ['one'], timeout: 5_000 }], - metadata: { includeUserContext: true }, + metadata: { + includeConversationData: true, + includeMCPMetadata: false, + includeUserContext: true, + }, label: 'keep group metadata', }], + PromptSubmit: [{ hooks: [{ type: 'command', command: 'prompt-hook' }] }], + Notification: [{ hooks: [{ type: 'command', command: 'notification-hook' }] }], PreToolUse: [{ matcher: 'launch-process', hooks: [{ type: 'command', command: 'user-command' }], }], }, }; - const fixture = await createFixture({ existingSettings: existing }); + const fixture = await createFixture({ settings: existing }); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const second = await runPlugin(fixture, 'install', { - agentName: 'augment', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + const firstRaw = await readFile(fixture.settingsPath, 'utf-8'); + const second = await fixture.install(); assert.equal(second.code, 0, second.stderr); - const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), firstRaw); + + const { settings } = await readSettings(fixture.settingsPath); assert.equal(settings.telemetryEnabled, false); assert.deepEqual(settings.hooks.SessionStart, existing.hooks.SessionStart); + assert.deepEqual(settings.hooks.PromptSubmit, existing.hooks.PromptSubmit); + assert.deepEqual(settings.hooks.Notification, existing.hooks.Notification); assert.deepEqual(settings.hooks.PreToolUse[0], existing.hooks.PreToolUse[0]); assert.equal(settings.hooks.PreToolUse.length, 2); assert.equal(settings.hooks.PostToolUse.length, 1); - assert.equal(settings.hooks.PreToolUse[1].matcher, '.*'); for (const [event, wrapperPath] of [ ['PreToolUse', fixture.guardWrapperPath], ['PostToolUse', fixture.auditWrapperPath], @@ -197,10 +91,23 @@ test('Auggie install preserves user settings and is idempotent', async () => { assert.equal(handler.type, 'command'); assert.equal(handler.timeout, 10_000); } + + const runtimeConfig = JSON.parse( + await readFile(path.join(fixture.agentDir, 'config.json'), 'utf-8'), + ); + assert.deepEqual(runtimeConfig, { + org_id: 'org-1', + agent_id: 'agent-1', + kid: 'kid-1', + base_url: fixture.baseUrl, + token: 'token-1', + agent_name: 'augment', + }); + assert.equal(await readFile(path.join(fixture.agentDir, 'private.key'), 'utf-8'), VALID_PRIVATE_KEY); + assert.match(await readFile(fixture.hookScriptPath, 'utf-8'), /const NATIVE_PAYLOAD = true;/); const guardWrapper = await readFile(fixture.guardWrapperPath, 'utf-8'); - const auditWrapper = await readFile(fixture.auditWrapperPath, 'utf-8'); - assert.match(guardWrapper, new RegExp(path.basename(fixture.guardScriptPath).replace('.', '\\.'))); - assert.match(auditWrapper, new RegExp(path.basename(fixture.hookScriptPath).replace('.', '\\.'))); + assert.match(guardWrapper, /guard\.js/); + assert.match(await readFile(fixture.auditWrapperPath, 'utf-8'), new RegExp('hook\\.js')); if (process.platform !== 'win32') { assert.equal((await stat(fixture.guardWrapperPath)).mode & 0o111, 0o100); assert.match(guardWrapper, /^#!\/bin\/sh\nexec /); @@ -208,128 +115,17 @@ test('Auggie install preserves user settings and is idempotent', async () => { assert.match(guardWrapper, /^@echo off\r?\n/); assert.match(guardWrapper, /exit \/b %errorlevel%/); } - await assert.rejects( - readFile(path.join(fixture.workspaceDir, '.augment', 'settings.json')), - { code: 'ENOENT' }, - ); - await assert.rejects( - readFile(path.join(fixture.workspaceDir, '.augment', 'settings.local.json')), - { code: 'ENOENT' }, - ); + await assertMissing(path.join(fixture.projectDir, '.augment', 'settings.json')); } finally { await fixture.close(); } }); -test('Auggie wrappers block freezes and forward official input byte-for-byte', async () => { - const capturePath = path.join(os.tmpdir(), `elydora-augment-event-${process.pid}-${Date.now()}.json`); - const hookSource = ` - const fs = require('node:fs'); - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, Buffer.concat(chunks))); - `; - const fixture = await createFixture({ hookSource }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - const guard = managedHandler(settings, 'PreToolUse', fixture.guardWrapperPath); - const audit = managedHandler(settings, 'PostToolUse', fixture.auditWrapperPath); - const prePayload = JSON.stringify({ - hook_event_name: 'PreToolUse', - conversation_id: 'conversation-1', - workspace_roots: [fixture.workspaceDir], - tool_name: 'launch-process', - tool_input: { command: 'echo test' }, - is_mcp_tool: false, - }); - const guardResult = await runCommand(guard.command, prePayload); - assert.equal(guardResult.code, 2); - assert.match(guardResult.stderr, /Agent is frozen by Elydora/); - const postPayload = JSON.stringify({ - hook_event_name: 'PostToolUse', - conversation_id: 'conversation-1', - workspace_roots: [fixture.workspaceDir], - tool_name: 'launch-process', - tool_input: { command: 'echo test' }, - tool_output: 'test', - is_mcp_tool: false, - }); - const auditResult = await runCommand(audit.command, postPayload); - assert.equal(auditResult.code, 0, auditResult.stderr); - assert.equal(await readFile(capturePath, 'utf-8'), postPayload); - } finally { - await fixture.close(); - await rm(capturePath, { force: true }); - } -}); - -test('Auggie status requires a complete pair, core runtimes, and wrappers', async () => { +test('Auggie install replaces stale managed handlers and preserves user groups', async () => { const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - let status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, true); - await rm(fixture.guardWrapperPath); - status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, false); - await runPlugin(fixture, 'install', { - agentName: 'augment', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); - await rm(fixture.hookScriptPath); - status = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(status.stdout).installed, false); - } finally { - await fixture.close(); - } -}); - -test('Auggie uninstall removes exact ownership and preserves user groups', async () => { - const fixture = await createFixture({ existingSettings: { - owner: 'user', - hooks: { Notification: [] }, - } }); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - settings.hooks.PreToolUse.unshift({ hooks: [], label: 'keep empty group' }); - settings.hooks.PreToolUse[1].hooks.push({ type: 'command', command: 'user-command' }); - settings.hooks.PreToolUse.push({ hooks: [{ - type: 'command', - command: generatedCommand(`${fixture.guardWrapperPath}.backup`), - timeout: 10_000, - }] }); - settings.hooks.PreToolUse.push({ hooks: [{ - type: 'command', - command: generatedCommand(path.join(fixture.agentDir, '..', 'agent-10', guardWrapperName)), - timeout: 10_000, - }] }); - await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); - const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; - const result = await runPlugin(fixture, 'uninstall', uninstallId); - assert.equal(result.code, 0, result.stderr); - const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8')); - assert.equal(remaining.owner, 'user'); - assert.deepEqual(remaining.hooks.Notification, []); - assert.equal(remaining.hooks.PreToolUse.length, 4); - assert.deepEqual(remaining.hooks.PreToolUse[0], { hooks: [], label: 'keep empty group' }); - assert.equal(remaining.hooks.PreToolUse[1].hooks[0].command, 'user-command'); - assert.match(JSON.stringify(remaining), /augment-guard.*backup/); - assert.match(JSON.stringify(remaining), /agent-10/); - assert.equal(remaining.hooks.PostToolUse, undefined); - } finally { - await fixture.close(); - } -}); - -test('Auggie install replaces stale Elydora handlers and preserves empty groups', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const settings = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); settings.hooks.PreToolUse.unshift({ hooks: [], label: 'keep empty group' }); for (const [event, wrapperName] of [ ['PreToolUse', guardWrapperName], @@ -341,15 +137,10 @@ test('Auggie install replaces stale Elydora handlers and preserves empty groups' timeout: 10_000, }] }); } - await writeFile(fixture.configPath, JSON.stringify(settings, null, 2)); - const result = await runPlugin(fixture, 'install', { - agentName: 'augment', - agentId: 'agent-1', - guardScriptPath: fixture.guardScriptPath, - hookScriptPath: fixture.hookScriptPath, - }); + await writeFile(fixture.settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + const result = await fixture.install(); assert.equal(result.code, 0, result.stderr); - const current = JSON.parse(await readFile(fixture.configPath, 'utf-8')); + const current = (await readSettings(fixture.settingsPath)).settings; assert.doesNotMatch(JSON.stringify(current), /agent-old/); assert.deepEqual(current.hooks.PreToolUse[0], { hooks: [], label: 'keep empty group' }); assert.equal(current.hooks.PreToolUse.length, 2); @@ -359,23 +150,59 @@ test('Auggie install replaces stale Elydora handlers and preserves empty groups' } }); -test('Auggie uninstall removes settings owned entirely by Elydora', async () => { - const fixture = await createFixture(); +test('Auggie uninstall removes exact ownership and preserves user settings', async () => { + const fixture = await createFixture({ settings: { + owner: 'user', + hooks: { Notification: [] }, + } }); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + settings.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'user-command' }); + settings.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: generatedCommand(`${fixture.guardWrapperPath}.backup`), + timeout: 10_000, + }] }); + settings.hooks.PreToolUse.push({ hooks: [{ + type: 'command', + command: generatedCommand(path.join(fixture.agentDir, '..', 'agent-10', guardWrapperName)), + timeout: 10_000, + }] }); + await writeFile(fixture.settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + const uninstallId = process.platform === 'win32' ? 'AGENT-1' : 'agent-1'; + const result = await runPlugin(fixture, 'uninstall', uninstallId); assert.equal(result.code, 0, result.stderr); - await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' }); + const remaining = (await readSettings(fixture.settingsPath)).settings; + assert.equal(remaining.owner, 'user'); + assert.deepEqual(remaining.hooks.Notification, []); + assert.equal(remaining.hooks.PreToolUse[0].hooks[0].command, 'user-command'); + assert.match(JSON.stringify(remaining), /augment-guard.*backup/); + assert.match(JSON.stringify(remaining), /agent-10/); + assert.equal(remaining.hooks.PostToolUse, undefined); } finally { await fixture.close(); } }); -test('Auggie rejects malformed settings and invalid hook contracts before writes', async () => { +test('Auggie uninstall removes settings owned entirely by Elydora', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const result = await runPlugin(fixture, 'uninstall', 'agent-1'); + assert.equal(result.code, 0, result.stderr); + await assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } +}); + +test('Auggie rejects malformed settings and invalid official hook contracts before writes', async () => { const cases = [ '{ malformed', 'null', '[]', + '{"hooks":{},"hooks":{}}', { hooks: null }, { hooks: { UnknownEvent: [] } }, { hooks: { PreToolUse: null } }, @@ -389,64 +216,33 @@ test('Auggie rejects malformed settings and invalid hook contracts before writes { hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'x', timeout: 0 }] }] } }, { hooks: { PreToolUse: [{ hooks: [], metadata: { includeUserContext: 'yes' } }] } }, ]; - for (const existingSettings of cases) { - const fixture = await createFixture({ existingSettings }); + for (const settings of cases) { + const fixture = await createFixture({ settings }); try { - assert.equal(fixture.installResult.code, 1, `${JSON.stringify(existingSettings)}\n${fixture.installResult.stderr}`); - const expected = typeof existingSettings === 'string' - ? existingSettings - : JSON.stringify(existingSettings, null, 2); - assert.equal(await readFile(fixture.configPath, 'utf-8'), expected); - await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); - await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); + const before = await readFile(fixture.settingsPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1, `${JSON.stringify(settings)}\n${result.stderr}`); + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), before); + await assertMissing(fixture.guardWrapperPath); + await assertMissing(fixture.auditWrapperPath); + await assertMissing(fixture.guardScriptPath); } finally { await fixture.close(); } } }); -test('Auggie install rejects missing runtimes before creating files', async () => { - const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-augment-missing-')); - const workspaceDir = path.join(homeDir, 'workspace'); - const agentDir = path.join(homeDir, '.elydora', 'agent-1'); - const fixture = { homeDir, workspaceDir }; - await mkdir(workspaceDir); +test('Auggie validates complete install config before creating files', async () => { + const fixture = await createFixture(); try { const result = await runPlugin(fixture, 'install', { - agentName: 'augment', - agentId: 'agent-1', - guardScriptPath: path.join(agentDir, 'guard.js'), - hookScriptPath: path.join(agentDir, 'hook.js'), + ...installConfig(fixture), + privateKey: 'invalid', }); assert.equal(result.code, 1); - assert.match(result.stderr, /runtime is missing/i); - await assert.rejects(readFile(path.join(homeDir, '.augment', 'settings.json')), { code: 'ENOENT' }); - await assert.rejects(readFile(path.join(agentDir, guardWrapperName)), { code: 'ENOENT' }); - } finally { - await rm(homeDir, { recursive: true, force: true }); - } -}); - -test('Auggie status surfaces malformed referenced runtime metadata', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); - const status = await runPlugin(fixture, 'status', null); - assert.equal(status.code, 1); - assert.match(status.stderr, /parse Elydora runtime config/i); - } finally { - await fixture.close(); - } -}); - -test('Auggie atomic writes leave no temporary files', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - for (const directory of [fixture.agentDir, path.dirname(fixture.configPath)]) { - assert.equal((await readdir(directory)).some((name) => name.endsWith('.tmp')), false); - } + assert.match(result.stderr, /canonical 32-byte base64url/i); + await assertMissing(fixture.settingsPath); + await assertMissing(fixture.guardScriptPath); } finally { await fixture.close(); } diff --git a/sdks/node/test/augment-runtime.test.mjs b/sdks/node/test/augment-runtime.test.mjs new file mode 100644 index 0000000..065d0eb --- /dev/null +++ b/sdks/node/test/augment-runtime.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + managedHandler, + readSettings, + runHandler, + runPlugin, + startApiServer, +} from '../test-support/augment-test-helpers.mjs'; + +async function status(fixture) { + const result = await runPlugin(fixture, 'status', null); + return { result, value: result.code === 0 ? JSON.parse(result.stdout) : undefined }; +} + +test('Auggie guard propagates exit code 2 for a frozen agent', async () => { + const api = await startApiServer({ status: 'frozen' }); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const settings = (await readSettings(fixture.settingsPath)).settings; + const guard = managedHandler(settings, 'PreToolUse', fixture.guardWrapperPath); + const payload = JSON.stringify({ + hook_event_name: 'PreToolUse', + conversation_id: 'conversation-1', + workspace_roots: [fixture.projectDir], + tool_name: 'launch-process', + tool_input: { command: 'echo test' }, + is_mcp_tool: false, + }); + const result = await runHandler(guard, payload, fixture); + assert.equal(result.code, 2, result.stderr); + assert.match(result.stderr, /Agent "augment" is frozen/); + assert.equal(api.requests.filter((request) => request.method === 'GET').length, 1); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Auggie audit preserves the complete native hook payload', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + const install = await fixture.install(); + assert.equal(install.code, 0, install.stderr); + const settings = (await readSettings(fixture.settingsPath)).settings; + const audit = managedHandler(settings, 'PostToolUse', fixture.auditWrapperPath); + const payload = { + hook_event_name: 'PostToolUse', + conversation_id: 'conversation-1', + workspace_roots: [fixture.projectDir], + tool_name: 'launch-process', + tool_input: { command: 'echo test', nested: { preserve: true } }, + tool_output: { stdout: 'test', stderr: '', exit_code: 0 }, + is_mcp_tool: false, + conversation_data: [{ role: 'user', content: 'preserve this' }], + mcp_metadata: { server: 'local', transport: 'stdio' }, + user_context: { account: 'user-1' }, + future_field: { survives: ['exactly', 2] }, + }; + const result = await runHandler(audit, JSON.stringify(payload), fixture); + assert.equal(result.code, 0, result.stderr); + const request = api.requests.find((candidate) => candidate.method === 'POST'); + assert(request); + assert.deepEqual(JSON.parse(request.raw).payload, payload); + } finally { + await fixture.close(); + await api.close(); + } +}); + +test('Auggie status requires settings, exact runtime identity, key, scripts, and wrappers', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + assert.equal((await status(fixture)).value.installed, true); + + await rm(fixture.guardWrapperPath); + assert.equal((await status(fixture)).value.installed, false); + assert.equal((await fixture.install()).code, 0); + + await rm(fixture.hookScriptPath); + assert.equal((await status(fixture)).value.installed, false); + assert.equal((await fixture.install()).code, 0); + + await writeFile(fixture.auditWrapperPath, 'tampered wrapper\n'); + assert.equal((await status(fixture)).value.installed, false); + assert.equal((await fixture.install()).code, 0); + + const keyPath = path.join(fixture.agentDir, 'private.key'); + await writeFile(keyPath, 'invalid'); + const invalidKey = await status(fixture); + assert.equal(invalidKey.result.code, 1); + assert.match(invalidKey.result.stderr, /private key is invalid/i); + } finally { + await fixture.close(); + } +}); + +test('Auggie status surfaces malformed and mismatched runtime metadata', async (t) => { + for (const [name, contents, pattern] of [ + ['malformed', '{ malformed', /parse Elydora runtime config/i], + ['duplicate', '{"agent_name":"augment","agent_name":"augment"}', /duplicate field/i], + ['mismatched', JSON.stringify({ + org_id: 'org-1', + agent_id: 'other-agent', + kid: 'kid-1', + base_url: 'https://api.elydora.com', + agent_name: 'augment', + }), /identity does not match/i], + ['unsupported field', JSON.stringify({ + org_id: 'org-1', + agent_id: 'agent-1', + kid: 'kid-1', + base_url: 'https://api.elydora.com', + agent_name: 'augment', + hidden: true, + }), /unsupported field/i], + ]) { + await t.test(name, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await writeFile(path.join(fixture.agentDir, 'config.json'), contents); + const current = await status(fixture); + assert.equal(current.result.code, 1); + assert.match(current.result.stderr, pattern); + } finally { + await fixture.close(); + } + }); + } +}); + +test('Auggie status ignores incomplete managed hook pairs', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const { settings } = await readSettings(fixture.settingsPath); + delete settings.hooks.PostToolUse; + await writeFile(fixture.settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + const current = await status(fixture); + assert.equal(current.result.code, 0, current.result.stderr); + assert.equal(current.value.hookConfigured, false); + assert.equal(current.value.installed, false); + } finally { + await fixture.close(); + } +}); diff --git a/sdks/node/test/augment-transaction.test.mjs b/sdks/node/test/augment-transaction.test.mjs new file mode 100644 index 0000000..bbbcc8e --- /dev/null +++ b/sdks/node/test/augment-transaction.test.mjs @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + createFixture, + installConfig, + installationModuleUrl, + ioModuleUrl, + managedInstallationModuleUrl, + runNode, + writeJson, +} from '../test-support/augment-test-helpers.mjs'; + +const managedFileNames = [ + 'guard.js', + 'config.json', + 'private.key', + 'hook.js', + `augment-guard${process.platform === 'win32' ? '.cmd' : '.sh'}`, + `augment-hook${process.platform === 'win32' ? '.cmd' : '.sh'}`, +]; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +async function snapshotInstallation(fixture) { + const entries = await Promise.all(managedFileNames.map(async (name) => [ + path.join(fixture.agentDir, name), + await readFile(path.join(fixture.agentDir, name), 'utf-8'), + ])); + entries.push([fixture.settingsPath, await readFile(fixture.settingsPath, 'utf-8')]); + return new Map(entries); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, expected] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), expected, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(path.dirname(fixture.settingsPath)), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +function testEnvironment(fixture, overrides = {}) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)), + }; +} + +test('Auggie rolls back all seven files when a transaction commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = ` + import { rename } from 'node:fs/promises'; + import { readConfig } from ${JSON.stringify(ioModuleUrl)}; + import { + commitAugmentInstallation, + prepareAugmentInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readConfig(); + const prepared = await prepareAugmentInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitAugmentInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 5) throw new Error('injected rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + testEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Auggie detects concurrent settings changes before runtime commit', async () => { + const fixture = await createFixture({ settings: { telemetryEnabled: true } }); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"telemetryEnabled":false,"hooks":{"Notification":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readConfig } from ${JSON.stringify(ioModuleUrl)}; + import { + commitAugmentInstallation, + prepareAugmentInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readConfig(); + const prepared = await prepareAugmentInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + await writeFile(document.configPath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await commitAugmentInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...testEnvironment(fixture, { orgId: 'updated' }), + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Augment Code CLI installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.settingsPath) { + assert.equal(await readFile(filePath, 'utf-8'), expected); + } + } + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Auggie rejects a stale settings document before staging files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '{"hooks":{"Notification":[]}}\n'; + const source = ` + import { writeFile } from 'node:fs/promises'; + import { readConfig } from ${JSON.stringify(ioModuleUrl)}; + import { prepareAugmentInstallation } from ${JSON.stringify(installationModuleUrl)}; + const document = await readConfig(); + await writeFile(document.configPath, process.env.ELYDORA_CONCURRENT, 'utf-8'); + await prepareAugmentInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { ...testEnvironment(fixture), ELYDORA_CONCURRENT: concurrent }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + for (const [filePath, expected] of before) { + if (filePath !== fixture.settingsPath) { + assert.equal(await readFile(filePath, 'utf-8'), expected); + } + } + assert.equal(await readFile(fixture.settingsPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Auggie rejects orphaned and mismatched runtime identities before writes', async (t) => { + await t.test('orphaned runtime', async () => { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity cannot be verified/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n'); + await assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } + }); + + await t.test('mismatched identity', async () => { + const fixture = await createFixture(); + try { + await writeJson(path.join(fixture.agentDir, 'config.json'), { + agent_name: 'augment', + agent_id: 'another-agent', + }); + const configPath = path.join(fixture.agentDir, 'config.json'); + const before = await readFile(configPath, 'utf-8'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity does not match/i); + assert.equal(await readFile(configPath, 'utf-8'), before); + await assertMissing(fixture.settingsPath); + } finally { + await fixture.close(); + } + }); +}); + +test('Auggie rejects linked settings, runtime directories, and wrappers', async (t) => { + for (const kind of ['settings', 'runtime', 'wrapper']) { + await t.test(kind, async () => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + if (kind === 'settings') { + await mkdir(path.dirname(fixture.settingsPath), { recursive: true }); + const targetFile = path.join(target, 'settings.json'); + await writeFile(targetFile, '{}\n'); + await symlink(targetFile, fixture.settingsPath, 'file'); + } else if (kind === 'runtime') { + await mkdir(fixture.homeDir, { recursive: true }); + await symlink(target, path.join(fixture.homeDir, '.elydora'), 'junction'); + } else { + assert.equal((await fixture.install()).code, 0); + const targetFile = path.join(target, 'wrapper'); + await writeFile(targetFile, 'external wrapper\n'); + await rm(fixture.guardWrapperPath); + await symlink(targetFile, fixture.guardWrapperPath, 'file'); + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical (directory|file)/i); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } finally { + await fixture.close(); + } + }); + } +}); + +test('Managed runtime extensions reject paths outside the agent directory', async () => { + const fixture = await createFixture(); + try { + const source = ` + import { prepareManagedInstallation } from ${JSON.stringify(managedInstallationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + await prepareManagedInstallation({ + agentKey: 'augment', + displayName: 'Augment Code CLI', + hookSources: [{ + directoryLabel: 'Auggie configuration directory', + label: 'Auggie user settings', + filePath: process.env.ELYDORA_SETTINGS_PATH, + source: '{}\\n', + }], + runtimeFiles: [{ + fileName: '../outside.js', + label: 'Unsafe runtime', + source: 'unsafe', + mode: 448, + }], + config, + }, 'guard.js', 'hook.js'); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...testEnvironment(fixture), + ELYDORA_SETTINGS_PATH: fixture.settingsPath, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /fileName must be a basename/i); + await assertMissing(path.join(fixture.agentDir, '..', 'outside.js')); + } finally { + await fixture.close(); + } +}); + +test('Auggie installation leaves no transaction artifacts', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +});