From f7dacff7e90383d1e23ad17706720deac74c9e00 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Fri, 9 Oct 2026 12:32:51 -0400 Subject: [PATCH] feat: invoke a trusted skill for one console turn --- COMMANDS.md | 32 +++- README.md | 6 + docs/generated/commands.md | 8 +- scripts/generate-docs.ts | 2 +- src/commands/chat.ts | 47 +++++- src/commands/command_manifest_data.ts | 40 +++++ src/commands/console_input.ts | 4 +- src/commands/one_turn_skill.ts | 21 +++ src/commands/slash.ts | 3 + src/commands/slash_context.ts | 16 +- src/core/skills/skill_discovery.ts | 16 +- src/core/skills/skill_settings.ts | 33 ++--- src/core/skills/skill_store_read.ts | 39 +++++ src/core/skills/skill_trust.ts | 53 ++----- test/composer_tty.test.ts | 20 +++ test/console_queue_tty.test.ts | 23 ++- test/one_turn_skill.test.ts | 201 ++++++++++++++++++++++++++ test/skill_store_read.test.ts | 64 ++++++++ 18 files changed, 542 insertions(+), 86 deletions(-) create mode 100644 src/commands/one_turn_skill.ts create mode 100644 src/core/skills/skill_store_read.ts create mode 100644 test/one_turn_skill.test.ts create mode 100644 test/skill_store_read.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index 8e0d5ebd..63a0d691 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -25,13 +25,13 @@ aether # no args = interactive REPL `help`, `auth`, `models`, `model`, `switch`, `agent`, `agents`, `tier`, `effort`, `audit`, `doctor`, `settings`, -`voice`, `preview`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `plan`, `research`, `project-review`, -`code-review`, `writing-skills`, `writing-plans`, `shell-profile`, `shell-result`, `shell-reset`, `terminal`, `terminal-attach`, `terminal-stop`, `terminal-status`, `queue`, `steer`, -`btw`, `pin`, `drop`, `context`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`, `memory`, -`workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`, `tree`, -`broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `review`, `ship`, `revert`, `photogen`, -`frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud`, `agent-create`, `browser`, -`ats` +`voice`, `preview`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `skill`, `plan`, `research`, +`project-review`, `code-review`, `writing-skills`, `writing-plans`, `shell-profile`, `shell-result`, `shell-reset`, `terminal`, `terminal-attach`, `terminal-stop`, `terminal-status`, `queue`, +`steer`, `btw`, `pin`, `drop`, `context`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`, +`memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`, +`tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `review`, `ship`, `revert`, +`photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud`, `agent-create`, +`browser`, `ats` ## Runtime capability requirements @@ -514,6 +514,23 @@ skill's content digest, so editing a trusted skill revokes that trust until you approve the new digest. A skill declaration narrows what the agent may do — it never grants a tool the host would otherwise refuse. +In an idle interactive console using a local model, `/skill +` loads an installed, enabled skill for just that task. For example, +`/skill user/fix-ci Investigate the failing unit test`. The task suffix is +sent literally, including extra spaces, newlines, quotes, `/`, and `!`; it is +never dispatched as another console command. The run header shows the resolved +skill ID, content digest, context size, and effective host tool limits. +`/context` identifies the last admitted turn; `/context next ` previews +normal next-turn defaults, or the current `/skill` command if one is supplied. +History and the slash picker restore editable text, +so submitting an old `/skill` line resolves its current digest and trust again. + +`/skill` refuses busy, queued, line-input, `--no-skills`, and server-executed +cloud chat use. Use `aether agent --skill ` for a hosted host-executed +run. A malformed or future-version local skill settings or trust store is +never treated as an empty store; incompatible data is reported and left +untouched until repaired. Normal skill defaults resume after this turn. + ### `aether capabilities [--available]` — what this build can actually do Prints the capability contract: tools, their side-effect class, and the @@ -623,6 +640,7 @@ Each starts an agent loop in the REPL. | `/self-review` | Review your own recent work. | | `/recon ` | Deep reconnaissance pass over the codebase. | | `/plan ` | Write an implementation plan. | +| `/skill ` | Use a trusted skill for one idle local task. | | `/writing-plans ` | Write a plan to `.hermes/plans/`. | | `/research ` | Research → gather → summarize. | | `/project-review` | Full project review + summary. (Was `/review`; that name is now the change-review rail below.) | diff --git a/README.md b/README.md index fb465519..fc5a37be 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,12 @@ Type `/` at an idle raw terminal prompt to browse described commands. Use arrows or Tab to choose, Enter to insert editable command text, then Enter again to run it; Escape restores your earlier draft. +On a local model, `/skill user/fix-ci Investigate the failing unit test` uses +that trusted skill for one idle task, then restores your normal skill defaults. +The task text stays literal; the run header shows the resolved skill digest and +host tool limits. Busy, queued, line-input, `--no-skills`, and server-executed +cloud chat invocations are refused. [Skill details](COMMANDS.md#aether-skills-subcommand--inspect-trust-and-manage-agent-skills). + Use `/pin src/guide.md` to attach that file's complete, bounded UTF-8 content to each admitted coding turn. The file is read from the execution checkout, so a worktree turn uses its own copy. `/context` shows what the last turn actually diff --git a/docs/generated/commands.md b/docs/generated/commands.md index fe3318d2..85d0560e 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -1,5 +1,5 @@ - + # Generated command reference This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling. @@ -449,6 +449,12 @@ deep reconnaissance Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.recon` +#### `/skill ` + +use a trusted skill for one idle local task + +Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.skill` + #### `/plan ` write implementation plan diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index fcde77c8..9343d42e 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -79,7 +79,7 @@ const COMMAND_PLACEHOLDERS = new Set([ "command", "connect|status|disconnect", "connect|status|disconnect|pr|checks|ci|workflow|action", "desc|view|start|pause|resume|cancel|complete|note", "element", "file", "guidance", "id", "id|all", "id|section", "lang", "login|status|token|refresh|logout", "model", "msg", "n", "name", "neo|kronus", "note", "n|id", "order-id", "path", "prompt", - "q", "section", "subcommand", "tag", "tag|n|id", "target", "task", "title", "topic", "type", "uvt", "value", + "q", "qualified-id", "section", "subcommand", "tag", "tag|n|id", "target", "task", "title", "topic", "type", "uvt", "value", ]); export function normalizeEol(value: string): string { return value.replace(/\r\n?/g, "\n"); } diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 0bc7a1de..70115c32 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -99,6 +99,7 @@ import type { WorkflowViewerState } from "../ui/workflow_viewer.js"; import type { StreamFrame } from "../core/stream.js"; import type { BrainEvent, ToolName } from "../core/brain_protocol.js"; import { ConsoleTaskContinuation, accountFingerprint, consoleWorkspaceState, observedWorkspaceChanges, type ModelTarget, type ObservedTool } from "./model_continuation.js"; +import { parseOneTurnSkill } from "./one_turn_skill.js"; // Key decoding lives in ui/keys.ts (shared with pickers/viewers); re-exported // here so existing imports keep working. @@ -120,6 +121,8 @@ export interface TurnSkillOptions { capability?: RunCapability; explicitSkill?: string; noSkills?: boolean; + /** /skill requires a host-executed route for the resolved policy. */ + requireHostSkillEnforcement?: boolean; /** Local console authority, never serialized to Cloud. */ exec?: ToolExecutor; /** Host-observed results only; no model text or shell output. */ @@ -487,6 +490,12 @@ export async function runTurn( if (skillOpts.capability === "planning" && backend === "cloud") { throw new ChatTurnError("planning requires host-executed tools; this cloud chat route runs tools on the server. Use `aether agent --planning` or `aether agent --local --planning`.", undefined, false); } + if (skillOpts.explicitSkill && skillOpts.noSkills) { + throw new ChatTurnError("/skill is unavailable while --no-skills is active.", undefined, false); + } + if (skillOpts.requireHostSkillEnforcement && backend === "cloud") { + throw new ChatTurnError("/skill requires host-executed tools; this cloud chat route runs tools on the server. Switch to a local model or use aether agent --skill .", undefined, false); + } // The same seam `aether agent` uses (commands/code.ts). Opened per turn, not // per session, because automatic skill selection reads THIS prompt — a turn // that says "the CI is failing" should pull the CI skill and the next one @@ -526,7 +535,7 @@ export async function runTurn( // exactly the cases the header exists to report. if (run.contextTokens > 0 || run.session.notices.length > 0 || run.hasWarnings) { const header = run.headerLines.join("\n"); - if (header !== lastTurnHeader) { + if (skillOpts.requireHostSkillEnforcement || header !== lastTurnHeader) { lastTurnHeader = header; for (const line of run.headerLines) process.stderr.write(errTheme.dim(" " + line) + "\n"); } @@ -564,7 +573,7 @@ export async function runTurn( return await runCloudTurn(ctx, brief, lifecycle, boundedSignal.signal, onFrame, onPulsePaint, deadlineAt, deadlineMs); } catch (err) { const outcome = finalizeThrownTurn(lifecycle, err, ctx.cfg.baseUrl); - if (err instanceof ChatTurnError) throw err; + if (err instanceof ChatTurnError) throw err.outcome ? err : new ChatTurnError(err.message, outcome, err.rendered); throw attachTurnOutcome(err, outcome); } finally { clearTimeout(deadline); @@ -1598,7 +1607,9 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P viewerOpen = false; break; } - }, redrawInput, { ...skillOpts, capability: input.capability ?? "coding", onToolResult: tool => toolResults.push(tool), steer: steerChannel }); + }, redrawInput, { ...skillOpts, capability: input.capability ?? "coding", + ...(input.oneTurnSkill ? { explicitSkill: input.oneTurnSkill.reference, requireHostSkillEnforcement: true } : {}), + onToolResult: tool => toolResults.push(tool), steer: steerChannel }); const afterChanges = observedWorkspaceChanges(ctx.flags.cwd); continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation); if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n"); @@ -1645,7 +1656,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P // commit() clears the submitted line before the request starts. Put it // back only when the user has not typed ahead; otherwise preserve their // newer draft and leave the failed submission in history for recall. - const recovered = sharedShellResult || authFailure ? buf.value : recoverSubmittedPrompt(text, buf.value); + const recovered = sharedShellResult || authFailure ? buf.value : recoverSubmittedPrompt(input.oneTurnSkill?.source ?? text, buf.value); if (recovered !== buf.value) { buf.clear(); buf.insert(recovered); @@ -1817,6 +1828,29 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P return; } const queuePrefix = /^\s*\/queue[ \t]+/.exec(raw); + const oneTurnSkill = parseOneTurnSkill(queuePrefix ? raw.slice(queuePrefix[0].length) : raw); + if (oneTurnSkill) { + if (queuePrefix || busy) { + process.stdout.write("\n/skill runs only from the idle composer and cannot be queued. Draft preserved.\n"); + repaint(); return; + } + if (skillOpts.noSkills) { + process.stdout.write("\n/skill is unavailable while --no-skills is active. Draft preserved.\n"); + repaint(); return; + } + if (oneTurnSkill.kind === "usage") { + process.stdout.write("\n" + oneTurnSkill.message + "\n"); + repaint(); return; + } + remember(raw); + buf.commit(raw); + process.stdout.write("\n"); + busy = true; + await runAndDrain(queue.allocate({ kind: "chat", text: oneTurnSkill.task, + oneTurnSkill: { reference: oneTurnSkill.reference, source: oneTurnSkill.source } })); + renderHudLine(); repaint(); + return; + } const input = classifyConsoleInput(queuePrefix ? raw.slice(queuePrefix[0].length) : raw); const commit = (): void => { if (input.kind === "chat") { remember(buf.value); buf.commit(buf.value); } @@ -2400,6 +2434,11 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = { } const queuePrefix = /^\s*\/queue[ \t]+/.exec(rawLine); const line = queuePrefix ? rawLine.slice(queuePrefix[0].length) : rawLine; + if (parseOneTurnSkill(line)) { + process.stdout.write("/skill requires the idle interactive composer; line input is unchanged.\n"); + if (p) process.stdout.write(p + consoleShell.prompt()); + continue; + } if (continuation.pending && !line.trim().startsWith("/switch")) { process.stdout.write("Choose /switch continue, fresh, cancel, brief, or edit first; input was not sent.\n"); if (p) process.stdout.write(p + consoleShell.prompt()); diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index d23026ed..94e49ce6 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -3813,6 +3813,46 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "note": null } }, + { + "key": "slash:skill", + "surface": "slash", + "name": "skill", + "aliases": [], + "compatibilityAliases": [], + "deprecatedAliases": [], + "args": " ", + "summary": "use a trusted skill for one idle local task", + "detailedHelp": "/skill \nUse an installed, enabled and trusted skill for this one task. Requires an idle interactive composer and a local host-executed turn; --no-skills refuses it. The following task text is literal. The next turn resumes normal skill defaults.", + "section": "Agent Modes", + "hidden": false, + "permissionClass": "unknown", + "availability": { + "state": "runtime-dependent", + "capabilityRequirements": [] + }, + "telemetryName": "slash.skill", + "acceptedGlobalFlags": [], + "ownedFlags": {}, + "handler": { + "id": "handler:slash:skill", + "kind": "host", + "module": "src/commands/slash.ts", + "symbol": "handleSlash" + }, + "docs": { + "kind": "manifest", + "module": "src/commands/command_manifest_data.ts", + "symbol": "COMMAND_MANIFEST_SOURCE", + "target": "skill", + "usage": "/skill ", + "visible": true, + "disposition": "generated" + }, + "release": { + "disposition": "new", + "note": "One-turn trusted skill invocation in the idle local composer" + } + }, { "key": "slash:plan", "surface": "slash", diff --git a/src/commands/console_input.ts b/src/commands/console_input.ts index 7f4f4bbc..b849d9a7 100644 --- a/src/commands/console_input.ts +++ b/src/commands/console_input.ts @@ -50,7 +50,9 @@ export type ConsoleInput = | { kind: "profile"; action: "list" | "status" | "use"; profile?: "cmd" | "powershell" } | ShareInput | { kind: "error"; message: string } - | { kind: "chat"; text: string; capability?: RunCapability } + | { kind: "chat"; text: string; capability?: RunCapability; + /** Only idle /skill admission can set this; queued edits never resolve a skill. */ + oneTurnSkill?: { reference: string; source: string } } | { kind: "empty" }; export function classifyConsoleInput(raw: string): ConsoleInput { diff --git a/src/commands/one_turn_skill.ts b/src/commands/one_turn_skill.ts new file mode 100644 index 00000000..aafc96ae --- /dev/null +++ b/src/commands/one_turn_skill.ts @@ -0,0 +1,21 @@ +/** Parse only the command and skill reference. The task is immutable literal + * text after one required separator, including its remaining whitespace. */ +export type OneTurnSkillCommand = + | { kind: "invoke"; reference: string; task: string; source: string } + | { kind: "usage"; message: string }; + +export function parseOneTurnSkill(raw: string): OneTurnSkillCommand | null { + const command = /^[ \t]*\/skill(?=\s|$)/.exec(raw); + if (!command) return null; + const rest = raw.slice(command[0].length); + const leading = /^[\s]+/.exec(rest)?.[0] ?? ""; + const afterLeading = rest.slice(leading.length); + const reference = /^[^\s]+/.exec(afterLeading)?.[0] ?? ""; + const afterReference = afterLeading.slice(reference.length); + if (!reference || !/^\s/.test(afterReference)) { + return { kind: "usage", message: "usage: /skill " }; + } + const task = afterReference.slice(1); + if (!task.trim()) return { kind: "usage", message: "usage: /skill " }; + return { kind: "invoke", reference, task, source: raw }; +} diff --git a/src/commands/slash.ts b/src/commands/slash.ts index 26e86078..74a2b9e5 100644 --- a/src/commands/slash.ts +++ b/src/commands/slash.ts @@ -425,6 +425,9 @@ export async function handleSlash( case "code-review": out.write(`/${cmd} is handled directly in the interactive REPL.\n`); break; + case "skill": + out.write("/skill requires the idle interactive composer; it is unavailable in line input or other owners.\n"); + break; case "pin": await pinSlash(ctx, out, arg, line); break; diff --git a/src/commands/slash_context.ts b/src/commands/slash_context.ts index 53d5a379..7bd3d80a 100644 --- a/src/commands/slash_context.ts +++ b/src/commands/slash_context.ts @@ -17,6 +17,7 @@ import type { AuditEntry } from "../core/audit.js"; import { openRunSession } from "../core/skills/run_session.js"; import { sanitizeForTransport } from "../core/skills/context_packet.js"; import { applyPromptMode } from "./prompt_modes.js"; +import { parseOneTurnSkill } from "./one_turn_skill.js"; import type { AdmittedContext } from "../core/selected_context.js"; export interface ContextInspectorOptions { @@ -78,20 +79,29 @@ export async function contextSlash(ctx: AppContext, out: Writable, arg: string, if (command.startsWith("next ")) { const rawTask = command.slice("next ".length); if (!rawTask.trim()) { out.write("usage: /context next \n"); return; } - const mode = applyPromptMode(rawTask); + const oneTurnSkill = parseOneTurnSkill(rawTask); + if (oneTurnSkill?.kind === "usage") { out.write(oneTurnSkill.message + "\n"); return; } + if (oneTurnSkill?.kind === "invoke" && options.noSkills) { + out.write("Next-draft preview refused: /skill is unavailable while --no-skills is active.\n"); return; + } + if (oneTurnSkill?.kind === "invoke" && options.backend === "cloud") { + out.write("Next-draft preview refused: /skill requires host-executed tools; switch to a local model.\n"); return; + } + const mode = oneTurnSkill ? { handled: false as const } : applyPromptMode(rawTask); if (mode.error) { out.write(mode.error + "\n"); return; } if (mode.capability === "planning" && options.backend === "cloud") { out.write("Next-draft preview refused: /plan cannot run on server-executed cloud chat. Use aether agent --planning.\n"); return; } - const prompt = mode.handled ? mode.prompt! : rawTask; + const prompt = oneTurnSkill?.kind === "invoke" ? oneTurnSkill.task : mode.handled ? mode.prompt! : rawTask; const opened = openRunSession({ projectRoot: ctx.flags.cwd, prompt, selectedPins: registry.selectedPins(), selectedFileTransport: options.backend === "cloud" ? "unsupported" : "host", ...(mode.capability ? { capability: mode.capability } : {}), - ...(options.explicitSkill ? { explicitSkill: options.explicitSkill } : {}), + ...(oneTurnSkill?.kind === "invoke" ? { explicitSkill: oneTurnSkill.reference } + : options.explicitSkill ? { explicitSkill: options.explicitSkill } : {}), ...(options.noSkills ? { noSkills: true } : {}), allowIncompleteInstructionDiscovery: options.backend === "cloud", }); diff --git a/src/core/skills/skill_discovery.ts b/src/core/skills/skill_discovery.ts index c57a2604..e181530b 100644 --- a/src/core/skills/skill_discovery.ts +++ b/src/core/skills/skill_discovery.ts @@ -13,8 +13,8 @@ import { configDir } from "../config.js"; import { SKILL_BOUNDS } from "./skill_bounds.js"; import { calculateSkillDigest } from "./skill_digest.js"; import { validateSkillManifest, type SkillScope } from "./skill_schema.js"; -import { loadSkillSettings, lookupSkillSetting } from "./skill_settings.js"; -import { loadTrustStore, lookupTrust } from "./skill_trust.js"; +import { loadSkillSettings, lookupSkillSetting, type SkillSettingsStore } from "./skill_settings.js"; +import { loadTrustStore, lookupTrust, type SkillTrustStore } from "./skill_trust.js"; import type { SkillDescriptor, SkillIndex, SkillIndexError, SkillTrustState } from "./skill_types.js"; export interface DiscoveryOptions { @@ -70,6 +70,8 @@ function discoverOne( skillRoot: string, scope: SkillScope, projectRoot: string, + settings: SkillSettingsStore, + trustStore: SkillTrustStore, ): { descriptor?: SkillDescriptor; error?: SkillIndexError } { const manifestPath = join(skillRoot, "skill.json"); if (!existsSync(manifestPath)) { @@ -110,11 +112,10 @@ function discoverOne( // skill still runs (it is the user's own file) — recorded as trusted. trust = "trusted"; } else { - const lookup = lookupTrust(loadTrustStore(), trustKey, manifest.id, digest.sha256); + const lookup = lookupTrust(trustStore, trustKey, manifest.id, digest.sha256); trust = lookup.state; } - const settings = loadSkillSettings(); const setting = lookupSkillSetting(settings, trustKey, manifest.id); const enabled = setting ? setting.enabled : true; // Automatic selection: built-ins follow the manifest; user/project skills @@ -150,6 +151,11 @@ function discoverOne( * never silently by source order. */ export function discoverSkills(options: DiscoveryOptions): SkillIndex { + // Read both local authority stores once, even when no skill directory is + // present. A corrupt/future store is an admission refusal, never an empty + // index with default-enabled skills. + const settings = loadSkillSettings(); + const trustStore = loadTrustStore(); const targets: ScanTarget[] = [ { scope: "builtin", root: options.builtinRoot ?? builtinSkillsRoot() }, { scope: "user", root: userSkillsRoot() }, @@ -161,7 +167,7 @@ export function discoverSkills(options: DiscoveryOptions): SkillIndex { for (const target of targets) { for (const skillRoot of listSkillDirectories(target.root)) { - const result = discoverOne(skillRoot, target.scope, options.projectRoot); + const result = discoverOne(skillRoot, target.scope, options.projectRoot, settings, trustStore); if (result.error) { errors.push(result.error); continue; } const descriptor = result.descriptor; if (!descriptor) continue; diff --git a/src/core/skills/skill_settings.ts b/src/core/skills/skill_settings.ts index 98817a58..d287e0fb 100644 --- a/src/core/skills/skill_settings.ts +++ b/src/core/skills/skill_settings.ts @@ -3,9 +3,10 @@ // Absence of a record means: enabled, automatic only if the manifest says so // AND the scope defaults allow it (project skills additionally need trust). -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { mkdirSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { configDir } from "../config.js"; +import { readSkillStore } from "./skill_store_read.js"; export const SKILL_SETTINGS_SCHEMA_VERSION = 1; @@ -28,29 +29,13 @@ export function skillSettingsPath(): string { } export function loadSkillSettings(): SkillSettingsStore { - const path = skillSettingsPath(); - if (!existsSync(path)) return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; - try { - const raw = JSON.parse(readFileSync(path, "utf8")) as Record; - if (raw["schema_version"] !== SKILL_SETTINGS_SCHEMA_VERSION || !Array.isArray(raw["settings"])) { - return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; - } - const settings: SkillSetting[] = []; - for (const entry of raw["settings"] as unknown[]) { - if (typeof entry !== "object" || entry === null) continue; - const item = entry as Record; - if (typeof item["projectRoot"] !== "string" || typeof item["skillId"] !== "string") continue; - settings.push({ - projectRoot: item["projectRoot"], - skillId: item["skillId"], - enabled: item["enabled"] !== false, - automatic: item["automatic"] === true, - }); - } - return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings }; - } catch { - return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; - } + const settings = readSkillStore(skillSettingsPath(), "skill settings", SKILL_SETTINGS_SCHEMA_VERSION, "settings", (entry): entry is SkillSetting => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false; + const item = entry as Record; + return typeof item["projectRoot"] === "string" && typeof item["skillId"] === "string" + && typeof item["enabled"] === "boolean" && typeof item["automatic"] === "boolean"; + }); + return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings }; } export function saveSkillSetting(setting: SkillSetting): void { diff --git a/src/core/skills/skill_store_read.ts b/src/core/skills/skill_store_read.ts new file mode 100644 index 00000000..bacb02b5 --- /dev/null +++ b/src/core/skills/skill_store_read.ts @@ -0,0 +1,39 @@ +// Shared admission reader for local skill settings and trust. A present store +// is never interpreted as empty when its bytes or schema cannot be understood. +import { readFileSync } from "node:fs"; +import { SkillError } from "./skill_errors.js"; + +export function readSkillStore( + path: string, + label: string, + schemaVersion: number, + collection: string, + validEntry: (entry: unknown) => entry is T, +): readonly T[] { + let text: string; + try { text = readFileSync(path, "utf8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw new SkillError({ code: "skill.schema_invalid", detail: `${label} at ${path} cannot be read; repair it before using skills` }); + } + let raw: unknown; + try { raw = JSON.parse(text); } + catch { + throw new SkillError({ code: "skill.schema_invalid", detail: `${label} at ${path} is not valid JSON; repair it before using skills` }); + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new SkillError({ code: "skill.schema_invalid", detail: `${label} at ${path} has an invalid shape; repair it before using skills` }); + } + const object = raw as Record; + if (!Number.isInteger(object["schema_version"])) { + throw new SkillError({ code: "skill.schema_invalid", detail: `${label} at ${path} has no valid schema version; repair it before using skills` }); + } + if (object["schema_version"] !== schemaVersion) { + throw new SkillError({ code: "skill.version_incompatible", detail: `${label} at ${path} uses schema ${String(object["schema_version"])}; this CLI supports ${schemaVersion} and will not overwrite it` }); + } + const entries = object[collection]; + if (!Array.isArray(entries) || !entries.every(validEntry)) { + throw new SkillError({ code: "skill.schema_invalid", detail: `${label} at ${path} has invalid ${collection}; repair it before using skills` }); + } + return entries; +} diff --git a/src/core/skills/skill_trust.ts b/src/core/skills/skill_trust.ts index 04818067..08b5d918 100644 --- a/src/core/skills/skill_trust.ts +++ b/src/core/skills/skill_trust.ts @@ -5,9 +5,10 @@ // moves the skill to "changed · review required"; nothing implicit re-trusts it. // Built-ins are trusted via the signed package artifact and never appear here. -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { mkdirSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { configDir } from "../config.js"; +import { readSkillStore } from "./skill_store_read.js"; export const SKILL_TRUST_SCHEMA_VERSION = 1; @@ -37,44 +38,18 @@ export function trustStorePath(): string { } export function loadTrustStore(): SkillTrustStore { - const path = trustStorePath(); - if (!existsSync(path)) return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; - try { - const raw = JSON.parse(readFileSync(path, "utf8")) as Record; - if (raw["schema_version"] !== SKILL_TRUST_SCHEMA_VERSION || !Array.isArray(raw["records"])) { - // Unknown shape: treat as empty rather than guessing — callers see skills - // as untrusted, which fails closed. - return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; - } - const records: SkillTrustRecord[] = []; - for (const entry of raw["records"] as unknown[]) { - if (typeof entry !== "object" || entry === null) continue; - const item = entry as Record; - if ( - typeof item["projectRoot"] !== "string" || - typeof item["skillId"] !== "string" || - typeof item["version"] !== "string" || - typeof item["sha256"] !== "string" || - typeof item["trustedAt"] !== "string" || - (item["method"] !== "inspect" && item["method"] !== "explicit" && item["method"] !== "install") - ) continue; - records.push({ - projectRoot: item["projectRoot"], - repository: typeof item["repository"] === "string" ? item["repository"] : null, - skillId: item["skillId"], - version: item["version"], - sha256: item["sha256"], - trustedAt: item["trustedAt"], - method: item["method"], - requestedPermissions: Array.isArray(item["requestedPermissions"]) - ? (item["requestedPermissions"] as unknown[]).filter((p): p is string => typeof p === "string") - : [], - }); - } - return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records }; - } catch { - return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; - } + const records = readSkillStore(trustStorePath(), "skill trust", SKILL_TRUST_SCHEMA_VERSION, "records", (entry): entry is SkillTrustRecord => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false; + const item = entry as Record; + return typeof item["projectRoot"] === "string" && typeof item["skillId"] === "string" + && (item["repository"] === null || typeof item["repository"] === "string") + && typeof item["version"] === "string" && typeof item["sha256"] === "string" + && typeof item["trustedAt"] === "string" + && (item["method"] === "inspect" || item["method"] === "explicit" || item["method"] === "install") + && Array.isArray(item["requestedPermissions"]) + && item["requestedPermissions"].every((permission: unknown) => typeof permission === "string"); + }); + return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records }; } function saveTrustStore(store: SkillTrustStore): void { diff --git a/test/composer_tty.test.ts b/test/composer_tty.test.ts index 0e73991c..0ddb3563 100644 --- a/test/composer_tty.test.ts +++ b/test/composer_tty.test.ts @@ -143,6 +143,26 @@ test("slash picker inserts text on Enter; only a later Enter runs the command", }); }); +test("picker and history restore /skill as editable text without selecting a skill", async () => { + await withComposer(false, async ({ key, output, until, modelCalls, resize }) => { + resize(160, 24); + key("/ski"); + assert.match(output(), /> \/skill/); + key("\r"); // picker accepts text only + assert.equal(modelCalls(), 0); + assert.doesNotMatch(output(), /\/skill is unavailable while --no-skills is active/); + key("user/demo task"); + key("\r"); + await until("/skill is unavailable while --no-skills is active"); + key("\x15"); + key("\x12skill"); + assert.equal(modelCalls(), 0); + key("\r"); // search acceptance restores a draft only + assert.match(output().slice(output().lastIndexOf("\r\x1b[2K")), /\/skill user\/demo task/); + assert.equal(modelCalls(), 0); + }, { history: ["/skill user/demo task"] }); +}); + test("picker selection, Escape, history, paste and resize retain their input owners", async () => { await withComposer(false, async ({ key, output, resize, until }) => { key("/btw remembered\r"); diff --git a/test/console_queue_tty.test.ts b/test/console_queue_tty.test.ts index 5cb63e7b..93161848 100644 --- a/test/console_queue_tty.test.ts +++ b/test/console_queue_tty.test.ts @@ -237,6 +237,26 @@ test("TTY: a failed turn pauses pending entries until /queue resume; nothing sil }); }); +test("/skill refuses busy and queued admission before either can become chat prose", async () => { + await withConsole(async ({ bodies, output, submit, type, until, answer, waitForCall }) => { + submit("ordinary running task"); + await waitForCall(1); + submit("/skill user/demo /clear !literal"); + await until(() => output().includes("/skill runs only from the idle composer and cannot be queued"), "busy skill refusal"); + type("\x15"); // refused draft remains editable + submit("/queue /skill user/demo task"); + await until(() => (output().match(/cannot be queued/g) ?? []).length >= 2, "queued skill refusal"); + assert.equal(bodies.length, 1); + assert.doesNotMatch(bodies[0]!, /user\/demo|!literal/); + type("\x15"); + await answer(1); + await until(() => output().includes('"type":"turn_outcome"'), "normal turn finished"); + type("\x1b[200~/skill user/demo task\x1b[201~\r"); + await until(() => output().includes("/skill is unavailable while --no-skills is active"), "disabled skills refused"); + assert.equal(bodies.length, 1); + }); +}); + test("TTY: cancelling a streaming turn discards and lists pending entries; none run", async () => { await withConsole(async ({ root, bodies, output, submit, until, waitForCall }) => { submit("long task"); @@ -306,11 +326,12 @@ test("line mode has no pending queue: management explains itself and /queue { output += text; }, true), input); - input.end(["/queue list", "/queue edit q1 something", "/queue clear", touch("ran.txt").replace("!", "/queue !"), ""].join("\n")); + input.end(["/queue list", "/queue edit q1 something", "/queue clear", "/skill user/demo literal", touch("ran.txt").replace("!", "/queue !"), ""].join("\n")); assert.equal(await session, 0); assert.equal((output.match(/Line mode has no pending queue/g) ?? []).length, 3); assert.equal(existsSync(join(root, "ran.txt")), true, "/queue runs as the next line"); assert.equal(modelCalls, 0); + assert.match(output, /\/skill requires the idle interactive composer/); assert.doesNotMatch(output, /\x1b\[/, "line mode does not render the ANSI slash picker"); } finally { globalThis.fetch = oldFetch; process.stdout.write = oldWrite; diff --git a/test/one_turn_skill.test.ts b/test/one_turn_skill.test.ts new file mode 100644 index 00000000..f166e0ea --- /dev/null +++ b/test/one_turn_skill.test.ts @@ -0,0 +1,201 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { parseOneTurnSkill } from "../src/commands/one_turn_skill.js"; +import { ChatTurnError, repl, runTurn } from "../src/commands/chat.js"; +import { contextSlash } from "../src/commands/slash_context.js"; +import { DEFAULT_CONFIG } from "../src/core/config.js"; +import { ApiClient } from "../src/core/transport.js"; +import { saveSkillSetting } from "../src/core/skills/skill_settings.js"; +import { getRegistry } from "../src/core/context_registry.js"; +import type { AppContext } from "../src/core/context.js"; +import type { TokenStore } from "../src/core/auth.js"; + +test("/skill parses only its reference and preserves every task-suffix byte", () => { + const raw = "/skill user/demo \n /clear !echo `x` $(whoami) \"quoted\" "; + assert.deepEqual(parseOneTurnSkill(raw), { + kind: "invoke", reference: "user/demo", task: " \n /clear !echo `x` $(whoami) \"quoted\" ", source: raw, + }); + assert.equal(parseOneTurnSkill("look at /skill user/demo task"), null); + assert.equal(parseOneTurnSkill("/skills list"), null); + assert.deepEqual(parseOneTurnSkill("/skill user/demo"), { kind: "usage", message: "usage: /skill " }); +}); + +function installUserSkill(configRoot: string): void { + const root = join(configRoot, "skills", "user", "demo"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "skill.json"), JSON.stringify({ + schema_version: 1, id: "user/demo", version: "1.0.0", name: "Demo", description: "One turn fixture", + triggers: { commands: [], phrases: [], automatic: false }, + tools: { allowed: ["read_file"], required: [], denied: [] }, + permissions: { requires: [], may_request: [], forbids: [] }, + context: { max_tokens: 500, resources: [] }, + outputs: { kinds: ["diagnosis"], verification: [] }, + dependencies: { skills: [] }, + compatibility: { min_agent_version: "0.1.0", capability_contract: 1 }, health: {}, + })); + writeFileSync(join(root, "SKILL.md"), "# ONE_TURN_SKILL_MARKER\nRead only.\n"); +} + +test("a supported local turn gets the skill once, then normal defaults resume", async () => { + const base = mkdtempSync(join(tmpdir(), "aether-one-turn-skill-")); + const config = join(base, "config"); + mkdirSync(config); + installUserSkill(config); + const priorConfig = process.env["AETHER_CONFIG_DIR"]; + const priorBackend = process.env["AETHER_BACKEND"]; + const priorFetch = globalThis.fetch; + process.env["AETHER_CONFIG_DIR"] = config; + process.env["AETHER_BACKEND"] = "local"; + const requests: Record[] = []; + let failNext = false; + globalThis.fetch = (async (_url, init) => { + requests.push(JSON.parse(String(init?.body)) as Record); + if (failNext) { failNext = false; throw new Error("fixture model failure"); } + return Response.json({ choices: [{ message: { content: "done" } }] }); + }) as typeof fetch; + const tokens = { get: async () => null } as unknown as TokenStore; + const ctx = { cfg: { ...DEFAULT_CONFIG, backend: "local" }, flags: { cwd: base, local: true, json: true, yes: false, audit: false }, + tokens, api: new ApiClient("https://example.invalid", tokens), confirm: async () => false } as AppContext; + try { + const task = " /clear !echo $(whoami)\nsecond line "; + await runTurn(ctx, task, undefined, undefined, undefined, + { explicitSkill: "user/demo", requireHostSkillEnforcement: true }); + assert.equal(requests.length, 1); + const first = JSON.stringify(requests[0]); + assert.match(first, /ONE_TURN_SKILL_MARKER/); + assert.match(first, /user\/demo/); + assert.match(first, /read_file/); + const messages = requests[0]?.["messages"] as Array<{ content: string }>; + assert.ok(messages.some(message => message.content.includes(task)), "literal task suffix must reach the model"); + assert.deepEqual(getRegistry().lastAdmitted?.descriptor.skills.map(skill => skill.id), ["user/demo"]); + assert.match(getRegistry().lastAdmitted?.descriptor.skills[0]?.digest ?? "", /^sha256:[a-f0-9]{64}$/); + const preview = new PassThrough(); + let previewText = ""; + preview.on("data", chunk => { previewText += String(chunk); }); + await contextSlash(ctx, preview, "next /skill user/demo fresh task", { backend: "local" }); + assert.match(previewText, /Next-draft preview \(not admitted or sent\)/); + assert.match(previewText, /user\/demo · sha256:[a-f0-9]{64} · explicit/); + assert.equal(requests.length, 1, "context preview makes no model request"); + const unsupported = new PassThrough(); + let unsupportedText = ""; + unsupported.on("data", chunk => { unsupportedText += String(chunk); }); + await contextSlash(ctx, unsupported, "next /skill user/demo task", { backend: "cloud" }); + assert.match(unsupportedText, /preview refused: \/skill requires host-executed tools/); + await runTurn(ctx, "next ordinary task"); + assert.equal(requests.length, 2); + assert.doesNotMatch(JSON.stringify(requests[1]), /ONE_TURN_SKILL_MARKER/); + assert.deepEqual(getRegistry().lastAdmitted?.descriptor.skills.filter(skill => skill.id === "user/demo"), []); + failNext = true; + await assert.rejects(runTurn(ctx, "failing selected task", undefined, undefined, undefined, + { explicitSkill: "user/demo", requireHostSkillEnforcement: true })); + await runTurn(ctx, "after model failure"); + assert.equal(requests.length, 4); + assert.doesNotMatch(JSON.stringify(requests[3]), /ONE_TURN_SKILL_MARKER/); + saveSkillSetting({ projectRoot: "*", skillId: "user/demo", enabled: false, automatic: false }); + await assert.rejects(runTurn(ctx, "refused", undefined, undefined, undefined, + { explicitSkill: "user/demo", requireHostSkillEnforcement: true }), /skill.disabled/); + assert.equal(requests.length, 4, "disabled selection must not reach the model"); + await runTurn(ctx, "after refusal"); + assert.equal(requests.length, 5); + assert.doesNotMatch(JSON.stringify(requests[4]), /ONE_TURN_SKILL_MARKER/); + } finally { + globalThis.fetch = priorFetch; + if (priorConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; else process.env["AETHER_CONFIG_DIR"] = priorConfig; + if (priorBackend === undefined) delete process.env["AETHER_BACKEND"]; else process.env["AETHER_BACKEND"] = priorBackend; + rmSync(base, { recursive: true, force: true }); + } +}); + +test("cloud host-enforcement and --no-skills refuse before any model request", async () => { + const root = mkdtempSync(join(tmpdir(), "aether-one-turn-cloud-")); + const priorFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { calls++; throw new Error("unexpected model request"); }) as typeof fetch; + const tokens = { get: async () => "token" } as unknown as TokenStore; + const ctx = { cfg: { ...DEFAULT_CONFIG, backend: "cloud" }, flags: { cwd: root, json: true, yes: false, audit: false }, + tokens, api: new ApiClient("https://example.invalid", tokens), confirm: async () => false } as AppContext; + try { + await assert.rejects(runTurn(ctx, "task", undefined, undefined, undefined, + { explicitSkill: "user/demo", requireHostSkillEnforcement: true }), + (error: unknown) => error instanceof ChatTurnError && error.outcome?.state === "failed" && /host-executed tools/.test(error.message)); + await assert.rejects(runTurn(ctx, "task", undefined, undefined, undefined, + { explicitSkill: "user/demo", noSkills: true, requireHostSkillEnforcement: true }), /--no-skills/); + assert.equal(calls, 0); + } finally { globalThis.fetch = priorFetch; rmSync(root, { recursive: true, force: true }); } +}); + +test("idle TTY /skill admits one literal task; a busy queued turn keeps normal defaults", async () => { + const base = mkdtempSync(join(tmpdir(), "aether-one-turn-tty-")); + const config = join(base, "config"); + mkdirSync(config); + installUserSkill(config); + const priorConfig = process.env["AETHER_CONFIG_DIR"]; + const priorBackend = process.env["AETHER_BACKEND"]; + const priorFetch = globalThis.fetch; + const priorWrite = process.stdout.write; + const priorErrWrite = process.stderr.write; + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); + const columns = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + let output = ""; + const requests: Record[] = []; + const firstGate: { release?: () => void } = {}; + let pending: Promise | null = null; + const until = async (check: () => boolean): Promise => { + const deadline = Date.now() + 5_000; + while (!check()) { + if (Date.now() > deadline) throw new Error("timed out waiting for TTY skill turn: " + output.slice(-600)); + await new Promise(resolve => setTimeout(resolve, 5)); + } + }; + const send = (text: string): void => { process.stdin.emit("data", Buffer.from(text)); }; + process.env["AETHER_CONFIG_DIR"] = config; + process.env["AETHER_BACKEND"] = "local"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdout, "columns", { value: 200, configurable: true }); + process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; + process.stderr.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stderr.write; + globalThis.fetch = (async (_url, init) => { + requests.push(JSON.parse(String(init?.body)) as Record); + if (requests.length === 1) await new Promise(resolve => { firstGate.release = resolve; }); + return Response.json({ choices: [{ message: { content: "done" } }] }); + }) as typeof fetch; + const tokens = { get: async () => null } as unknown as TokenStore; + const ctx = { cfg: { ...DEFAULT_CONFIG, backend: "local" }, flags: { cwd: base, local: true, json: true, yes: false, audit: false }, + tokens, api: new ApiClient("https://example.invalid", tokens), confirm: async () => false } as AppContext; + try { + pending = repl(ctx); + await until(() => output.includes("\x1b[?2004h")); + send("\x1b[200~/skill user/demo /clear !literal\nline \x1b[201~\r"); + await until(() => requests.length === 1); + send("ordinary queued task\r"); + await until(() => output.includes("Queued q2 (chat")); + firstGate.release?.(); + await until(() => requests.length === 2); + await until(() => (output.match(/"type":"turn_outcome"/g) ?? []).length === 2); + const firstMessages = requests[0]?.["messages"] as Array<{ content: string }>; + assert.ok(firstMessages.some(message => message.content.includes("ONE_TURN_SKILL_MARKER"))); + assert.ok(firstMessages.some(message => message.content.includes(" /clear !literal\nline "))); + assert.doesNotMatch(JSON.stringify(requests[1]), /ONE_TURN_SKILL_MARKER/); + send("/exit\r\r"); + assert.equal(await pending, 0); + pending = null; + } finally { + firstGate.release?.(); + if (pending) { send("\x03\x03\x04"); await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1_000))]); } + globalThis.fetch = priorFetch; + process.stdout.write = priorWrite; + process.stderr.write = priorErrWrite; + if (tty) Object.defineProperty(process.stdin, "isTTY", tty); else delete (process.stdin as { isTTY?: boolean }).isTTY; + if (raw) Object.defineProperty(process.stdin, "setRawMode", raw); else delete (process.stdin as { setRawMode?: unknown }).setRawMode; + if (columns) Object.defineProperty(process.stdout, "columns", columns); else delete (process.stdout as { columns?: number }).columns; + if (priorConfig === undefined) delete process.env["AETHER_CONFIG_DIR"]; else process.env["AETHER_CONFIG_DIR"] = priorConfig; + if (priorBackend === undefined) delete process.env["AETHER_BACKEND"]; else process.env["AETHER_BACKEND"] = priorBackend; + rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/test/skill_store_read.test.ts b/test/skill_store_read.test.ts new file mode 100644 index 00000000..dc1c9c8f --- /dev/null +++ b/test/skill_store_read.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverSkills } from "../src/core/skills/skill_discovery.js"; +import { loadSkillSettings, saveSkillSetting, skillSettingsPath } from "../src/core/skills/skill_settings.js"; +import { loadTrustStore, recordTrust, removeTrust, trustStorePath } from "../src/core/skills/skill_trust.js"; +import { openRunSession } from "../src/core/skills/run_session.js"; +import { SkillError } from "../src/core/skills/skill_errors.js"; + +function fixture(run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "aether-skill-store-")); + const previous = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = root; + try { run(root); } + finally { + if (previous === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = previous; + rmSync(root, { recursive: true, force: true }); + } +} + +const setting = { projectRoot: "*", skillId: "user/example", enabled: true, automatic: false }; +const trust = { projectRoot: "*", repository: null, skillId: "user/example", version: "1.0.0", + sha256: "a".repeat(64), trustedAt: "2026-10-09T00:00:00Z", method: "inspect" as const, requestedPermissions: [] }; + +for (const [name, body, expected] of [ + ["future version", '{"schema_version":2,"settings":[{"projectRoot":"*","skillId":"user/example","enabled":false,"automatic":false}]}', "skill.version_incompatible"], + ["invalid JSON", "{broken", "skill.schema_invalid"], + ["missing version", '{"settings":[]}', "skill.schema_invalid"], + ["invalid record", '{"schema_version":1,"settings":[{"projectRoot":"*","skillId":"user/example","enabled":"false","automatic":false}]}', "skill.schema_invalid"], +] as const) { + test(`settings ${name} refuse reads, writes and turn admission without changing bytes`, () => fixture(root => { + const path = skillSettingsPath(); + writeFileSync(path, body); + assert.throws(() => loadSkillSettings(), (error: unknown) => error instanceof SkillError && error.code === expected); + assert.throws(() => saveSkillSetting(setting), (error: unknown) => error instanceof SkillError && error.code === expected); + assert.throws(() => discoverSkills({ projectRoot: root }), (error: unknown) => error instanceof SkillError && error.code === expected); + const opened = openRunSession({ projectRoot: root, prompt: "task", explicitSkill: "user/example" }); + assert.equal(opened.ok, false); + if (!opened.ok) assert.equal(opened.refusal.code, expected); + assert.equal(readFileSync(path, "utf8"), body); + })); +} + +for (const [name, body, expected] of [ + ["future version", '{"schema_version":2,"records":[]}', "skill.version_incompatible"], + ["invalid JSON", "{broken", "skill.schema_invalid"], + ["missing version", '{"records":[]}', "skill.schema_invalid"], + ["invalid record", '{"schema_version":1,"records":[{"projectRoot":"*"}]}', "skill.schema_invalid"], +] as const) { + test(`trust ${name} refuse reads and writes without changing bytes`, () => fixture(root => { + const path = trustStorePath(); + writeFileSync(path, body); + assert.throws(() => loadTrustStore(), (error: unknown) => error instanceof SkillError && error.code === expected); + assert.throws(() => recordTrust(trust), (error: unknown) => error instanceof SkillError && error.code === expected); + assert.throws(() => removeTrust("*", "user/example"), (error: unknown) => error instanceof SkillError && error.code === expected); + const opened = openRunSession({ projectRoot: root, prompt: "task", explicitSkill: "user/example" }); + assert.equal(opened.ok, false); + if (!opened.ok) assert.equal(opened.refusal.code, expected); + assert.equal(readFileSync(path, "utf8"), body); + })); +}