diff --git a/collectivus-plugin-kernel-types.d.ts b/collectivus-plugin-kernel-types.d.ts index 416a38c..20271dd 100644 --- a/collectivus-plugin-kernel-types.d.ts +++ b/collectivus-plugin-kernel-types.d.ts @@ -133,12 +133,18 @@ export interface PluginContributionManifest { sinks?: PluginSinkManifest[] datasets?: PluginDatasetManifest[] skills?: PluginSkillManifest[] + agents?: PluginAgentManifest[] init_presets?: PluginInitPresetManifest[] } export interface PluginClientManifest { name: string skill_dir: string + /** + * Per-client subagent directory relative to the user's home (e.g. + * `.claude/agents`). Absent for clients without a subagent concept. + */ + agent_dir?: string attach_probe?: PluginAttachProbeManifest required_upstreams?: string[] } @@ -192,6 +198,12 @@ export interface PluginSkillManifest { source_dir?: string } +export interface PluginAgentManifest { + name: string + clients: PluginSkillClient[] + source_file?: string +} + export interface PluginInitPresetManifest { name: string summary?: string @@ -293,6 +305,7 @@ export interface PluginActivationContext { */ storage: QueryStorageService skills: SkillRegistry + agents: AgentRegistry initPresets: InitPresetRegistry /** * Backfill provider registry (kernel-owned). Plugins register @@ -541,6 +554,12 @@ export interface CommandRunContext { * to materialize plugin-contributed skills under per-client paths. */ skills: SkillRegistry + /** + * Agent registry (kernel-owned). Populated by the dispatcher. + * `hyp agents install` and the walkthrough enumerate this to + * materialize plugin-contributed subagents under per-client paths. + */ + agents: AgentRegistry /** * Source registry (kernel-owned). Populated by the dispatcher. * `hyp status` and the Phase 9 walkthrough enumerate this to render @@ -1288,6 +1307,24 @@ export interface SkillContribution { projectLocal?: boolean } +export interface AgentRegistry { + register(agent: AgentContribution): void + list(): AgentContribution[] +} + +/** + * A custom subagent contributed by a client-adapter plugin. Unlike + * skills (a directory tree around a `SKILL.md`), an agent is a single + * markdown definition file installed flat into the per-client agent + * directory as `/.md`. + */ +export interface AgentContribution { + name: string + plugin: PluginName + clients: PluginSkillClient[] + sourceFile: string +} + export interface InitPresetRegistry { register(preset: InitPresetContribution): void get(name: string): InitPresetContribution | undefined diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 3c41598..62a6b47 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -65,9 +65,9 @@ fields (validated by `src/core/manifest.js`): | `permissions` | no | String array, e.g. `["network", "read_env"]`. | | `requires` | no | `{ plugins?, capabilities? }` — see [Capabilities](#capabilities). | | `provides` | no | `{ capabilities? }` — see [Capabilities](#capabilities). | -| `contributes` | no | What the plugin adds: `sources`, `sinks`, `datasets`, `commands`, `skills`, `init_presets`, `config_sections`, `client`. | +| `contributes` | no | What the plugin adds: `sources`, `sinks`, `datasets`, `commands`, `skills`, `agents`, `init_presets`, `config_sections`, `client`. | -Each entry under `contributes.{sources,sinks,datasets,commands,skills,init_presets}` +Each entry under `contributes.{sources,sinks,datasets,commands,skills,agents,init_presets}` needs a non-empty `name`; `config_sections` entries use `section`. --- @@ -97,7 +97,7 @@ on the registries hanging off `ctx`. The kernel handles dependency order, paths, logging, and lifecycle. `ctx` gives you: - `ctx.sources`, `ctx.sinks`, `ctx.query`, `ctx.commands`, `ctx.skills`, - `ctx.initPresets`, `ctx.configRegistry` — the registries. + `ctx.agents`, `ctx.initPresets`, `ctx.configRegistry` — the registries. - `ctx.requireCapability(name, range)` / `ctx.provideCapability(name, version, value)`. - `ctx.config` — the validated config slice for this plugin. - `ctx.paths` — `{ rootDir, stateDir, cacheDir, tempDir }`, created for you. @@ -208,6 +208,26 @@ ctx.skills.register({ }) ``` +### Agents + +Materialize a custom subagent into client agent directories (e.g. +`.claude/agents/`). Unlike a skill, an agent is a single markdown +definition file installed flat as `/.md`. Declare +`contributes.agents: [{ name, clients }]` and register: + +```js +ctx.agents.register({ + name: 'hypaware-widget-analyst', + plugin: PLUGIN_NAME, + clients: ['claude'], + sourceFile: '/abs/path/to/agents/hypaware-widget-analyst.md', +}) +``` + +Only clients whose manifest declares `contributes.client.agent_dir` +receive agents; targets without one are skipped with a warning by +`hyp agents install`. + ### Init presets Declare `contributes.init_presets: [{ name }]` and register a `run` that diff --git a/hypaware-core/plugins-workspace/claude/agents/hypaware-analyst.md b/hypaware-core/plugins-workspace/claude/agents/hypaware-analyst.md new file mode 100644 index 0000000..a6ca856 --- /dev/null +++ b/hypaware-core/plugins-workspace/claude/agents/hypaware-analyst.md @@ -0,0 +1,72 @@ +--- +name: hypaware-analyst +description: Worker for fan-out analysis of local HypAware recordings. Spawn one per independent slice based on date partition, gateway id, conversation id, user id, file glob, etc. when an analysis would otherwise require many `hyp query` runs or return large result sets. Each invocation receives a scope plus an explicit question and returns a short structured summary — never raw query output. +tools: Bash, Read, Grep, Glob +model: haiku +--- + +# HypAware Analyst Worker + +You are a worker spawned to analyze ONE slice of local HypAware recordings. Your job is to run the minimum number of `hyp query` commands needed to answer the question for your slice, then return a compact structured summary. + +## CLI essentials + +You run `hyp query` commands. These rules are non-negotiable. + +- **Use `--format json`** for anything you will parse. `--format markdown` only when you literally need a table for the lead. +- **Inline output is context-budgeted, not row-capped.** String cells truncate to ~200 chars (`…(+N)` markers) and rows are dropped past a ~32KB row-data budget, with a `notice: showing X of Y rows …` line on stderr. Prefer aggregates that fit the budget; when your slice genuinely needs a large result, spill it with `--output ` and post-process the file with Read/Grep instead of parsing stdout. +- **Narrow aggressively in SQL.** Add `WHERE` clauses on `date`, `gateway_id`, `conversation_id`, `user_id`, `message_created_at`, etc., until the slice matches what you are assigned. Filtering inside the SELECT is the only narrowing mechanism — `hyp query sql` does not take dataset-shaped flags like `--date` or `--gateway-id`. +- **Unfamiliar table?** Run `hyp query schema --format json` once, then query. Works for built-ins and `hyp collect`-registered tables. +- **`--config `** only when told the service uses a non-default config. Otherwise rely on what `hyp status` would discover. +- **Read-only SQL only.** SQL must be a single `SELECT`. The available `hyp query` subcommands are `schema`, `status`, `sql`, `refresh`, `maintain` — you are restricted to `schema`, `status`, and `sql`. Never run `refresh` or `maintain`, and never shell out to side effects. + +## Datasets you can query + +- `logs` — OTLP log records (HypAware OTel collector). +- `traces` — OTLP spans. +- `metrics` — OTLP metric points. +- `ai_gateway_messages` — one row per AI-gateway content part. Key columns: `conversation_id`, `message_id`, `message_index`, `part_id`, `part_index`, `role`, `part_type` (`text` | `reasoning` | `tool_call` | `tool_result` | passthrough), `tool_name`, `tool_call_id`, `tool_args`, `content_text`, `is_error`, `is_compact_summary`, `is_sidechain`, `cwd`, `git_branch`, `user_id`, `client_name`, `client_version`, `entrypoint`, `user_type`, `permission_mode`, `provider`, `model`, `hook_event`, `caller_type`, `attributes` (JSON: `gateway`, `client`, `request`, `timing`, sometimes `usage`), `status` (JSON: `tool_status`, sometimes `finish_reason`), `message_created_at`, `conversation_started_at`. Partition columns: `gateway_id`, `date`. +- Collection tables registered via `hyp collect` — see `hyp collect list` for what's available in the lead's setup. + +For exact columns in the installed version: `hyp query schema
--format json`. For the full reference on `hyp query`, read `~/.claude/skills/hypaware-query/SKILL.md`. + +## SQL hints + +- JSON columns (`attributes`, `status`, `tools`, `tool_args`, `raw_frame`, `previous_message_id`, `compact_metadata`) use `JSON_VALUE(col, '$.path')` for scalar extraction and `JSON_QUERY(col, '$.path')` for subtrees. `JSON_EXISTS` is **not** supported — use `JSON_QUERY(...) IS NOT NULL` instead. +- `is_error`, `is_sidechain`, `is_compact_summary` are direct boolean columns — prefer them over JSON probing or `content_text` substring matches. +- Token usage is recorded at `attributes.$.usage.*` when present, but **for Claude-via-gateway recordings this is typically null** — fall back to `attributes.$.gateway.request_bytes` and `attributes.$.gateway.response_bytes` as size proxies. +- Latency lives at `attributes.$.timing.latency_ms` (note: `latency_ms`, not `duration_ms`). +- Dedup usage/timing per message before summing: those fields can repeat across the parts of one message — `GROUP BY conversation_id, message_id` with `MAX(...)` first, then aggregate per conversation/user/etc. +- Tool call / result pairs join on `tool_call_id`. The natural ordering key for `ai_gateway_messages` is `(conversation_id, message_index, part_index)`. +- Table names are resolved from the SQL AST; only built-ins and registered collection tables are valid. + +## What to return + +Return a compact JSON-shaped summary. Keep it under ~50 lines. Examples of good shape: + +```json +{ + "scope": "date=2026-05-20, gateway_id=cli-laptop, user_id=86459ddf-...", + "counts": { "rows": 1234, "errors": 12, "distinct_conversations": 88 }, + "top": [ + { "tool_name": "Bash", "errors": 7 }, + { "tool_name": "Edit", "errors": 3 } + ], + "samples": [ + { "conversation_id": "abc123", "message_id": "f01a...", "note": "tool_status=error on git push" } + ], + "anomalies": ["3 traces > 30s, all POST /v1/messages from claude-cli/2.1.118"], + "commands_run": 4 +} +``` + +Rules for the summary: + +- **Never** paste raw query output. Counts, top-N, ids, and short prose only. +- Always include `scope` so the lead can merge across workers. +- If a query failed: return `{ "error": "...", "exit_code": N, "stderr": "..." }` and stop. Do not retry, and do not attempt to fix cache state — that is the lead's job. +- If the question turns out to need data outside your assigned scope, return `{ "out_of_scope": "what extra slice is needed" }` and let the lead spawn another worker. + +## Efficiency budget + +Aim to run **≤ 5** `hyp query` commands. If you find yourself running more, your slice is too broad or the question is too vague — return what you have plus `{ "needs_narrower_scope": true }`. diff --git a/hypaware-core/plugins-workspace/claude/hypaware.plugin.json b/hypaware-core/plugins-workspace/claude/hypaware.plugin.json index ffaec2e..6f10714 100644 --- a/hypaware-core/plugins-workspace/claude/hypaware.plugin.json +++ b/hypaware-core/plugins-workspace/claude/hypaware.plugin.json @@ -23,6 +23,7 @@ "client": { "name": "claude", "skill_dir": ".claude/skills", + "agent_dir": ".claude/agents", "attach_probe": { "format": "json", "settings_file": ".claude/settings.json", @@ -35,6 +36,9 @@ { "name": "hypaware-ignore", "clients": ["claude"] }, { "name": "hypaware-unignore", "clients": ["claude"] } ], + "agents": [ + { "name": "hypaware-analyst", "clients": ["claude"] } + ], "init_presets": [ { "name": "claude-and-otel-local", diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md index 87ccdb0..30aa86b 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-query/SKILL.md @@ -16,26 +16,28 @@ Use `hyp query` to inspect local HypAware recordings. It reads local JSONL recor - **Missing partitions still error.** Run the exact `hyp query refresh …` command the CLI prints, or rerun the target query with `--refresh always`. - Broad manual refreshes are explicit: `hyp query refresh --all [dataset]`. Do not run a broad refresh when the printed file-targeted command is enough. 4. Prefer structured output for analysis: use `--format json` for follow-up reasoning and `--format markdown` when showing a table to the user. Inline output is context-budgeted, not row-capped: each string cell is truncated to ~200 code points (a `…(+N)` marker shows how much was elided) and rows are dropped once a row-data byte budget (~32KB) is hit, with a `notice: showing X of Y rows …` line on stderr. To get a full, untruncated result, spill it to a file with `--output ` (prints only a receipt to stdout — the data never floods context) and post-process the file. Override the caps with `--max-cell ` / `--max-bytes ` (`0` disables either). -5. Use high-level query commands before custom SQL. Switch to `hyp query sql` only when the built-in commands cannot answer the question. -6. For unfamiliar SQL tables, run `hyp query schema
--format json` before querying. +5. For unfamiliar SQL tables, run `hyp query schema
--format json` before querying. Registered datasets can have different column sets even when they share a logical shape (e.g., per-user `agent_logs_*` S3 datasets) — check each table's schema before writing cross-table SQL. If `schema` reports `columns: 0` for a dataset that is still queryable, fall back to `SELECT * FROM
LIMIT 1`; failed queries also list the available columns in their error message. ## Common Commands ```bash hyp query status -hyp query catalog --format json -hyp query logs --since 1h --format json -hyp query traces slow --limit 20 --format json -hyp query metrics list --format json -hyp query metrics series --format json hyp query schema
--format json hyp query sql "" --format json +hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh hyp query refresh --all logs -hyp collect --name -hyp collect --glob '' --name +hyp collect list +hyp collect remove ``` +These are the only subcommands in the installed CLI (`hyp query`: schema, status, sql, refresh, maintain; `hyp collect`: list, remove). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. + +## SQL dialect notes + +- `json_extract_scalar()` does not exist. `JSON_EXTRACT` does, but it errors on rows where a JSON-typed column (notably `tool_args`) holds a plain string instead of a JSON object ("first argument must be JSON string or object, got string"). +- The robust pattern for extracting fields from `tool_args` is a regex over the raw text, e.g. `regexp_extract(CAST(tool_args AS VARCHAR), '"command":"([^"]+)', 1)`. + ## AI gateway message model Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages`. Each row is a normalized message content part owned by the HypAware AI gateway schema. @@ -54,6 +56,6 @@ Run `hyp query schema ai_gateway_messages --format markdown` for the authoritati ## Guardrails - Do not assume the cache auto-refreshes. Query commands default to `--refresh never`. -- Always read stderr. A successful exit code does not mean the cache is current. -- Keep SQL read-only and use only query tables from `hyp query catalog`. +- Always read stderr, and never pipe it to /dev/null (especially in shell loops over multiple datasets) — errors and staleness warnings land there, and an empty stdout is indistinguishable from zero rows. A successful exit code does not mean the cache is current. +- Keep SQL read-only and use only datasets listed by `hyp query status`. - `hyp query sql` inline output is context-budgeted (cells truncated to ~200 chars, rows dropped past a ~32KB row-data budget) and emits a `notice:` on stderr when it withholds rows — it is not a fixed row cap. Prefer aggregates/filters for analysis; use `--output ` for a complete, untruncated result and read it back from the file rather than from stdout. diff --git a/hypaware-core/plugins-workspace/claude/src/index.js b/hypaware-core/plugins-workspace/claude/src/index.js index c922fef..887c143 100644 --- a/hypaware-core/plugins-workspace/claude/src/index.js +++ b/hypaware-core/plugins-workspace/claude/src/index.js @@ -251,6 +251,14 @@ export async function activate(ctx) { }) } + const agentsRoot = path.resolve(skillsRootDir(), 'agents') + ctx.agents.register({ + name: 'hypaware-analyst', + plugin: PLUGIN_NAME, + clients: ['claude'], + sourceFile: path.join(agentsRoot, 'hypaware-analyst.md'), + }) + ctx.initPresets.register({ name: 'claude-and-otel-local', plugin: PLUGIN_NAME, diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md index 87ccdb0..30aa86b 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-query/SKILL.md @@ -16,26 +16,28 @@ Use `hyp query` to inspect local HypAware recordings. It reads local JSONL recor - **Missing partitions still error.** Run the exact `hyp query refresh …` command the CLI prints, or rerun the target query with `--refresh always`. - Broad manual refreshes are explicit: `hyp query refresh --all [dataset]`. Do not run a broad refresh when the printed file-targeted command is enough. 4. Prefer structured output for analysis: use `--format json` for follow-up reasoning and `--format markdown` when showing a table to the user. Inline output is context-budgeted, not row-capped: each string cell is truncated to ~200 code points (a `…(+N)` marker shows how much was elided) and rows are dropped once a row-data byte budget (~32KB) is hit, with a `notice: showing X of Y rows …` line on stderr. To get a full, untruncated result, spill it to a file with `--output ` (prints only a receipt to stdout — the data never floods context) and post-process the file. Override the caps with `--max-cell ` / `--max-bytes ` (`0` disables either). -5. Use high-level query commands before custom SQL. Switch to `hyp query sql` only when the built-in commands cannot answer the question. -6. For unfamiliar SQL tables, run `hyp query schema
--format json` before querying. +5. For unfamiliar SQL tables, run `hyp query schema
--format json` before querying. Registered datasets can have different column sets even when they share a logical shape (e.g., per-user `agent_logs_*` S3 datasets) — check each table's schema before writing cross-table SQL. If `schema` reports `columns: 0` for a dataset that is still queryable, fall back to `SELECT * FROM
LIMIT 1`; failed queries also list the available columns in their error message. ## Common Commands ```bash hyp query status -hyp query catalog --format json -hyp query logs --since 1h --format json -hyp query traces slow --limit 20 --format json -hyp query metrics list --format json -hyp query metrics series --format json hyp query schema
--format json hyp query sql "" --format json +hyp query sql "" --format jsonl --output # full result, lossless hyp query refresh hyp query refresh --all logs -hyp collect --name -hyp collect --glob '' --name +hyp collect list +hyp collect remove ``` +These are the only subcommands in the installed CLI (`hyp query`: schema, status, sql, refresh, maintain; `hyp collect`: list, remove). There are no high-level `catalog`/`logs`/`traces`/`metrics` query commands — answer questions with `hyp query sql`, and discover datasets from the `hyp query status` output. + +## SQL dialect notes + +- `json_extract_scalar()` does not exist. `JSON_EXTRACT` does, but it errors on rows where a JSON-typed column (notably `tool_args`) holds a plain string instead of a JSON object ("first argument must be JSON string or object, got string"). +- The robust pattern for extracting fields from `tool_args` is a regex over the raw text, e.g. `regexp_extract(CAST(tool_args AS VARCHAR), '"command":"([^"]+)', 1)`. + ## AI gateway message model Recorded AI-gateway traffic is exposed through one dataset: `ai_gateway_messages`. Each row is a normalized message content part owned by the HypAware AI gateway schema. @@ -54,6 +56,6 @@ Run `hyp query schema ai_gateway_messages --format markdown` for the authoritati ## Guardrails - Do not assume the cache auto-refreshes. Query commands default to `--refresh never`. -- Always read stderr. A successful exit code does not mean the cache is current. -- Keep SQL read-only and use only query tables from `hyp query catalog`. +- Always read stderr, and never pipe it to /dev/null (especially in shell loops over multiple datasets) — errors and staleness warnings land there, and an empty stdout is indistinguishable from zero rows. A successful exit code does not mean the cache is current. +- Keep SQL read-only and use only datasets listed by `hyp query status`. - `hyp query sql` inline output is context-budgeted (cells truncated to ~200 chars, rows dropped past a ~32KB row-data budget) and emits a `notice:` on stderr when it withholds rows — it is not a fixed row cap. Prefer aggregates/filters for analysis; use `--output ` for a complete, untruncated result and read it back from the file rather than from stdout. diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index 8c86ef8..729641d 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -13,6 +13,7 @@ import { runWalkthrough, runPickerWalkthrough } from './walkthrough.js' import { mergeInstalledManifestsIntoKnown, validateConfig } from '../config/validate.js' import { discoverInstalledPlugins } from '../runtime/installed.js' import { discoverBundledPlugins } from '../runtime/bundled.js' +import { isWithinDir } from '../runtime/contribution_names.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { collectHypAwareStatus } from '../daemon/status.js' import { applyContextControls, renderResult } from '../query/format.js' @@ -38,6 +39,7 @@ import { SCAFFOLD_KINDS, scaffoldPlugin } from '../plugin_doctor/scaffold.js' /** * @import { AiGatewayCapability, CommandRegistration, CommandRunContext, HypAwareV2Config, PluginName } from '../../../collectivus-plugin-kernel-types.d.ts' + * @import { ClientDescriptor } from '../plugin_catalog.js' * @import { ExtendedQueryStorageService } from '../cache/types.d.ts' * @import { PluginMetadata } from '../config/types.d.ts' * @import { DaemonInstallOptions, HypAwareStatusReport, ServiceState } from '../daemon/types.d.ts' @@ -238,6 +240,12 @@ function buildCoreCommands() { usage: 'hyp skills install [--client ]', run: runSkillsInstall, }, + { + name: 'agents install', + summary: 'Install registered subagents into AI client directories', + usage: 'hyp agents install [--client ]', + run: runAgentsInstall, + }, { name: 'daemon', summary: 'Manage the HypAware daemon (subcommands: install, uninstall, run, start, stop, restart, status)', @@ -2423,6 +2431,7 @@ async function runInit(argv, ctx) { capabilities: ctx.capabilities, sources: /** @type {any} */ (ctx.sources), skills: /** @type {any} */ (ctx.skills), + agents: /** @type {any} */ (ctx.agents), stdout: ctx.stdout, stderr: ctx.stderr, env: ctx.env, @@ -2630,6 +2639,7 @@ async function runPickerInit(flags, ctx) { capabilities: ctx.capabilities, sources: /** @type {any} */ (ctx.sources), skills: /** @type {any} */ (ctx.skills), + agents: /** @type {any} */ (ctx.agents), stdout: ctx.stdout, stderr: ctx.stderr, env: ctx.env, @@ -3045,13 +3055,13 @@ async function runSkillsInstall(argv, ctx) { return 1 } - const skillDirMap = await buildSkillDirMap() + const descriptorMap = await buildClientDescriptorMap() let count = 0 for (const skill of skills) { for (const targetClient of skill.clients) { if (parsed.client !== 'all' && parsed.client !== targetClient) continue - const skillDir = skillDirMap.get(targetClient) + const skillDir = descriptorMap.get(targetClient)?.skillDir if (!skillDir) { ctx.stderr.write(`warning: skill '${skill.name}' targets unknown client '${targetClient}'\n`) continue @@ -3073,22 +3083,87 @@ async function runSkillsInstall(argv, ctx) { } /** - * Build a map from client name to skill directory by reading plugin + * `hyp agents install [--client ]` + * + * Mirrors `hyp skills install` for subagent contributions. Each agent + * is a single markdown definition file materialized flat into the + * per-client agent directory as `/.md`; existing + * installations are replaced (idempotent). Clients without an + * `agent_dir` in their manifest are skipped with a warning. + * + * @param {string[]} argv + * @param {CommandRunContext} ctx + */ +async function runAgentsInstall(argv, ctx) { + const parsed = parseSkillsArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + + const agents = ctx.agents.list() + if (agents.length === 0) { + ctx.stdout.write('(no agents registered)\n') + return 0 + } + + const homeDir = ctx.env.HOME ?? process.env.HOME ?? '' + if (!homeDir) { + ctx.stderr.write('error: HOME is not set; cannot resolve agent install paths\n') + return 1 + } + + const descriptorMap = await buildClientDescriptorMap() + + let count = 0 + for (const agent of agents) { + for (const targetClient of agent.clients) { + if (parsed.client !== 'all' && parsed.client !== targetClient) continue + const agentDir = descriptorMap.get(targetClient)?.agentDir + if (!agentDir) { + ctx.stderr.write(`warning: agent '${agent.name}' targets client '${targetClient}' without an agent directory\n`) + continue + } + const baseDir = path.join(homeDir, agentDir) + const dest = path.join(baseDir, `${agent.name}.md`) + // Defense in depth: registration rejects traversal names, but the + // agent dir comes from a plugin manifest, so re-check containment. + if (!isWithinDir(dest, baseDir)) { + ctx.stderr.write(`warning: agent '${agent.name}' for ${targetClient} resolves outside ${baseDir}; skipped\n`) + continue + } + try { + await fs.mkdir(path.dirname(dest), { recursive: true }) + await fs.copyFile(agent.sourceFile, dest) + ctx.stdout.write(`installed agent '${agent.name}' → ${dest}\n`) + count += 1 + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + ctx.stderr.write(`warning: agent '${agent.name}' for ${targetClient} failed: ${message}\n`) + } + } + } + ctx.stdout.write(`installed ${count} agent copy(ies)\n`) + return 0 +} + +/** + * Build a map from client name to client descriptor by reading plugin * manifests. This avoids hardcoding `.claude/skills` / `.codex/skills` - * in core. + * / `.claude/agents` in core. * - * @returns {Promise>} + * @returns {Promise>} */ -async function buildSkillDirMap() { - /** @type {Map} */ +async function buildClientDescriptorMap() { + /** @type {Map} */ const map = new Map() try { const bundled = await discoverBundledPlugins() const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) for (const [clientName, descriptor] of catalog.clientDescriptors) { - map.set(clientName, descriptor.skillDir) + map.set(clientName, descriptor) } - } catch { /* discovery failure → empty map → warnings per skill */ } + } catch { /* discovery failure → empty map → warnings per contribution */ } return map } diff --git a/src/core/cli/dispatch.js b/src/core/cli/dispatch.js index 9b97e1b..0804b39 100644 --- a/src/core/cli/dispatch.js +++ b/src/core/cli/dispatch.js @@ -183,6 +183,7 @@ export async function dispatch(argv, opts = {}) { query: kernel.query, storage: kernel.storage, skills: kernel.skills, + agents: kernel.agents, sources: kernel.sources, sinks: kernel.sinks, initPresets: kernel.initPresets, diff --git a/src/core/cli/types.d.ts b/src/core/cli/types.d.ts index 16f4f8c..ec3101f 100644 --- a/src/core/cli/types.d.ts +++ b/src/core/cli/types.d.ts @@ -95,6 +95,9 @@ export interface RunPickerWalkthroughOptions { skills?: { list(): { name: string; clients: ('claude' | 'codex')[]; sourceDir: string }[] } + agents?: { + list(): { name: string; clients: ('claude' | 'codex')[]; sourceFile: string }[] + } stdout: NodeJS.WritableStream | { write(chunk: string): unknown } stderr: NodeJS.WritableStream | { write(chunk: string): unknown } stdin?: NodeJS.ReadableStream @@ -187,6 +190,7 @@ export interface FinaleSummary { } attach: { client: 'claude' | 'codex'; dryRun: boolean; ok: boolean }[] skillsInstalled: { name: string; client: 'claude' | 'codex'; dest: string; dryRun: boolean }[] + agentsInstalled: { name: string; client: 'claude' | 'codex'; dest: string; dryRun: boolean }[] daemonRestart: { skipped: boolean; dryRun: boolean; ok: boolean } /** Per-provider onboarding backfill outcomes (empty when none ran). */ backfill: BackfillFinaleResult[] diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index ccadf52..8b26e1b 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -8,6 +8,7 @@ import { Attr, getLogger, withSpan } from '../observability/index.js' import { defaultConfigPath } from '../config/schema.js' import { readObservabilityEnv } from '../observability/env.js' import { discoverBundledPlugins } from '../runtime/bundled.js' +import { isWithinDir } from '../runtime/contribution_names.js' import { buildPluginCatalog } from '../plugin_catalog.js' import { ensureDurableBinForNpx } from './global_install.js' import { detectClientSources } from './detect.js' @@ -25,6 +26,7 @@ export const WALKTHROUGH_CANCEL_EXIT_CODE = 130 /** * @import { AiGatewayCapability, CapabilityRegistry, HypAwareV2Config, PluginConfigInstance, PluginName, SinkConfigInstance } from '../../../collectivus-plugin-kernel-types.d.ts' + * @import { ClientDescriptor } from '../plugin_catalog.js' * @import { DaemonInstallOptions } from '../daemon/types.d.ts' * @import { ExtendedSinkRegistry, ExtendedSourceRegistry } from '../registry/types.d.ts' */ @@ -785,6 +787,7 @@ export async function runPickerWalkthrough(opts) { capabilities, sources: opts.sources, skills: opts.skills, + agents: opts.agents, config, configPath, env, @@ -844,6 +847,10 @@ export async function runPickerWalkthrough(opts) { const tag = finaleSummary.skillsInstalled[0].dryRun ? '(dry-run) ' : '' stdout.write(`${tag}skills: ${finaleSummary.skillsInstalled.length} copied\n`) } + if (finaleSummary?.agentsInstalled && finaleSummary.agentsInstalled.length > 0) { + const tag = finaleSummary.agentsInstalled[0].dryRun ? '(dry-run) ' : '' + stdout.write(`${tag}agents: ${finaleSummary.agentsInstalled.length} copied\n`) + } stdout.write(`next: hyp query sql 'select count(*) from logs'\n`) return { @@ -964,8 +971,9 @@ export function composePickerConfig(args) { /** * Run the picker finale: daemon install, attach, skills install, - * daemon restart. Each step emits its own span (`daemon.install`, - * `client.attach` (via the adapter), `skills.install`). + * agents install, daemon restart. Each step emits its own span + * (`daemon.install`, `client.attach` (via the adapter), + * `skills.install`, `agents.install`). * * @param {{ * finale: PickerFinaleActions, @@ -973,6 +981,7 @@ export function composePickerConfig(args) { * capabilities: CapabilityRegistry, * sources?: { stopAll?: () => Promise }, * skills?: { list(): { name: string, clients: ('claude'|'codex')[], sourceDir: string }[] }, + * agents?: { list(): { name: string, clients: ('claude'|'codex')[], sourceFile: string }[] }, * config: HypAwareV2Config, * configPath: string, * env: NodeJS.ProcessEnv, @@ -987,7 +996,7 @@ export function composePickerConfig(args) { * @returns {Promise} */ async function runPickerFinale(args) { - const { finale, clientsPicked, capabilities, sources, skills, config, configPath, env, stdout, stderr } = args + const { finale, clientsPicked, capabilities, sources, skills, agents, config, configPath, env, stdout, stderr } = args const dryRun = finale.dryRun === true const homeDir = env.HOME ?? '' @@ -1002,6 +1011,7 @@ async function runPickerFinale(args) { globalInstall: { skipped: true, installed: false }, attach: [], skillsInstalled: [], + agentsInstalled: [], daemonRestart: { skipped: true, dryRun, ok: false }, backfill: [], } @@ -1101,8 +1111,11 @@ async function runPickerFinale(args) { } } + const descriptorMap = clientsPicked.length > 0 && (skills || agents) + ? await buildWalkthroughClientDescriptorMap() + : new Map() + if (clientsPicked.length > 0 && skills) { - const skillDirMap = await buildWalkthroughSkillDirMap() await withSpan( 'skills.install', { @@ -1117,9 +1130,16 @@ async function runPickerFinale(args) { for (const skill of skills.list()) { for (const targetClient of skill.clients) { if (!clientsPicked.includes(targetClient)) continue - const skillDir = skillDirMap.get(targetClient) + const skillDir = descriptorMap.get(targetClient)?.skillDir if (!skillDir) continue - const dest = path.join(homeDir, skillDir, skill.name) + const baseDir = path.join(homeDir, skillDir) + const dest = path.join(baseDir, skill.name) + // Defense in depth: registration rejects traversal names, but the + // skill dir comes from a plugin manifest, so re-check containment. + if (!isWithinDir(dest, baseDir)) { + stderr.write(`warning: skill '${skill.name}' for ${targetClient} resolves outside ${baseDir}; skipped\n`) + continue + } // Separate the skills block from the preceding attach output. if (!printedAny) stdout.write('\n') printedAny = true @@ -1143,6 +1163,52 @@ async function runPickerFinale(args) { ) } + if (clientsPicked.length > 0 && agents) { + await withSpan( + 'agents.install', + { + [Attr.COMPONENT]: 'walkthrough', + [Attr.OPERATION]: 'agents.install', + dry_run: dryRun, + client_count: clientsPicked.length, + status: 'ok', + }, + async (span) => { + let printedAny = false + for (const agent of agents.list()) { + for (const targetClient of agent.clients) { + if (!clientsPicked.includes(targetClient)) continue + const agentDir = descriptorMap.get(targetClient)?.agentDir + if (!agentDir) continue + const baseDir = path.join(homeDir, agentDir) + const dest = path.join(baseDir, `${agent.name}.md`) + // Defense in depth: registration rejects traversal names, but the + // agent dir comes from a plugin manifest, so re-check containment. + if (!isWithinDir(dest, baseDir)) { + stderr.write(`warning: agent '${agent.name}' for ${targetClient} resolves outside ${baseDir}; skipped\n`) + continue + } + if (!printedAny) stdout.write('\n') + printedAny = true + if (dryRun) { + stdout.write(`(dry-run) Would install agent '${agent.name}' → ${dest}\n`) + } else { + await fs.mkdir(path.dirname(dest), { recursive: true }) + await fs.copyFile(agent.sourceFile, dest) + stdout.write(`installed agent '${agent.name}' → ${dest}\n`) + } + summary.agentsInstalled.push({ name: agent.name, client: targetClient, dest, dryRun }) + } + } + if (printedAny) stdout.write('\n') + if (span && typeof span.setAttribute === 'function') { + span.setAttribute('installed_count', summary.agentsInstalled.length) + } + }, + { component: 'walkthrough' } + ) + } + // Backfill: import each picked client's local history after the config // write and before the daemon (re)start that resumes live capture. // Runs independent of the daemon — `--no-daemon` still backfills, since @@ -1352,16 +1418,16 @@ function endpointFromListen(listen) { } /** - * @returns {Promise>} + * @returns {Promise>} */ -async function buildWalkthroughSkillDirMap() { - /** @type {Map} */ +async function buildWalkthroughClientDescriptorMap() { + /** @type {Map} */ const map = new Map() try { const bundled = await discoverBundledPlugins() const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) for (const [clientName, descriptor] of catalog.clientDescriptors) { - map.set(clientName, descriptor.skillDir) + map.set(clientName, descriptor) } } catch { /* discovery failure → empty map */ } return map diff --git a/src/core/config/validate.js b/src/core/config/validate.js index 5ce9a82..bc20b7a 100644 --- a/src/core/config/validate.js +++ b/src/core/config/validate.js @@ -617,6 +617,7 @@ function firstPartyClientDescriptors() { plugin: /** @type {PluginName} */ ('@hypaware/claude'), name: 'claude', skillDir: '.claude/skills', + agentDir: '.claude/agents', attachProbe: { format: 'json', settings_file: '.claude/settings.json', diff --git a/src/core/plugin_catalog.js b/src/core/plugin_catalog.js index a5f8e16..1596e92 100644 --- a/src/core/plugin_catalog.js +++ b/src/core/plugin_catalog.js @@ -19,6 +19,7 @@ * @property {PluginName} plugin * @property {string} name * @property {string} skillDir + * @property {string} [agentDir] * @property {PluginAttachProbeManifest} [attachProbe] * @property {string[]} [requiredUpstreams] */ @@ -92,6 +93,7 @@ export function buildPluginCatalog(bundledManifests, installedManifests = []) { name: client.name, skillDir: client.skill_dir, } + if (typeof client.agent_dir === 'string') descriptor.agentDir = client.agent_dir if (client.attach_probe) descriptor.attachProbe = client.attach_probe if (Array.isArray(client.required_upstreams)) { descriptor.requiredUpstreams = client.required_upstreams diff --git a/src/core/plugin_doctor/diagnose.js b/src/core/plugin_doctor/diagnose.js index 589cd62..5182458 100644 --- a/src/core/plugin_doctor/diagnose.js +++ b/src/core/plugin_doctor/diagnose.js @@ -32,6 +32,7 @@ const CONTRIBUTIONS = [ { key: 'datasets', nameField: 'name', label: 'dataset', register: "ctx.query.registerDataset({ name: '%s', ... })", anchor: 'registering-datasets' }, { key: 'commands', nameField: 'name', label: 'command', register: "ctx.commands.register({ name: '%s', plugin, run })", anchor: 'registering-commands' }, { key: 'skills', nameField: 'name', label: 'skill', register: "ctx.skills.register({ name: '%s', plugin, clients, sourceDir })", anchor: 'skills' }, + { key: 'agents', nameField: 'name', label: 'agent', register: "ctx.agents.register({ name: '%s', plugin, clients, sourceFile })", anchor: 'agents' }, { key: 'init_presets', nameField: 'name', label: 'init preset', register: "ctx.initPresets.register({ name: '%s', plugin, summary, run })", anchor: 'init-presets' }, ] diff --git a/src/core/plugin_doctor/dry_run.js b/src/core/plugin_doctor/dry_run.js index 978e7cb..8417add 100644 --- a/src/core/plugin_doctor/dry_run.js +++ b/src/core/plugin_doctor/dry_run.js @@ -138,6 +138,7 @@ function snapshotRegistry(runtime) { datasets: runtime.query.listDatasets().map((d) => d.name), commands: runtime.commands.list().map((c) => c.name), skills: runtime.skills.list().map((s) => s.name), + agents: runtime.agents.list().map((a) => a.name), init_presets: runtime.initPresets.list().map((p) => p.name), capabilities: runtime.capabilities .list() @@ -185,6 +186,7 @@ function emptySnapshot() { datasets: [], commands: [], skills: [], + agents: [], init_presets: [], capabilities: [], } diff --git a/src/core/plugin_doctor/types.d.ts b/src/core/plugin_doctor/types.d.ts index 1119870..8590bbe 100644 --- a/src/core/plugin_doctor/types.d.ts +++ b/src/core/plugin_doctor/types.d.ts @@ -55,6 +55,7 @@ export interface RegisteredSnapshot { datasets: string[] commands: string[] skills: string[] + agents: string[] init_presets: string[] capabilities: string[] } diff --git a/src/core/runtime/activation.d.ts b/src/core/runtime/activation.d.ts index 8fa7996..9575670 100644 --- a/src/core/runtime/activation.d.ts +++ b/src/core/runtime/activation.d.ts @@ -1,5 +1,6 @@ import type { ActivePlugin, + AgentRegistry, BackfillMaterializerRegistry, BackfillRegistry, CommandRegistry, @@ -37,6 +38,7 @@ export interface KernelRuntime { storage: ExtendedQueryStorageService cacheRoot: string skills: SkillRegistry + agents: AgentRegistry initPresets: InitPresetRegistry backfills: BackfillRegistry backfillMaterializers: BackfillMaterializerRegistry diff --git a/src/core/runtime/activation.js b/src/core/runtime/activation.js index cdb4c71..4d3ae16 100644 --- a/src/core/runtime/activation.js +++ b/src/core/runtime/activation.js @@ -12,9 +12,10 @@ import { createBackfillMaterializerRegistry, createBackfillRegistry } from '../r import { createSinkRegistry } from '../registry/sinks.js' import { createSourceRegistry } from '../registry/sources.js' import { createQueryStorageService } from '../cache/storage.js' +import { isSafeContributionName } from './contribution_names.js' /** - * @import { ActivePlugin, BackfillMaterializerRegistry, BackfillRegistry, CapabilityName, CapabilityRegistry, CommandRegistry, ConfigRegistry, InitPresetContribution, InitPresetRegistry, JsonObject, PermissionContext, PluginActivationContext, PluginLogger, PluginManifest, PluginName, PluginPaths, PluginPermission, QueryRegistry, SemverRange, SemverVersion, SinkRegistry, SkillContribution, SkillRegistry, SourceRegistry } from '../../../collectivus-plugin-kernel-types.d.ts' + * @import { ActivePlugin, AgentContribution, AgentRegistry, BackfillMaterializerRegistry, BackfillRegistry, CapabilityName, CapabilityRegistry, CommandRegistry, ConfigRegistry, InitPresetContribution, InitPresetRegistry, JsonObject, PermissionContext, PluginActivationContext, PluginLogger, PluginManifest, PluginName, PluginPaths, PluginPermission, QueryRegistry, SemverRange, SemverVersion, SinkRegistry, SkillContribution, SkillRegistry, SourceRegistry } from '../../../collectivus-plugin-kernel-types.d.ts' * @import { ExtendedQueryStorageService } from '../cache/types.d.ts' * @import { KernelRuntime } from './activation.d.ts' */ @@ -61,6 +62,7 @@ export function createKernelRuntime(opts = {}) { storage, cacheRoot: storage.cacheRoot, skills: createPhase2SkillRegistry(), + agents: createAgentRegistry(), initPresets: createInitPresetRegistry(), backfills: opts.backfillRegistry ?? createBackfillRegistry(), backfillMaterializers: opts.backfillMaterializerRegistry ?? createBackfillMaterializerRegistry(), @@ -115,6 +117,7 @@ export function createActivationContext({ runtime, plugin, paths, config, env }) query: runtime.query, storage: runtime.storage, skills: runtime.skills, + agents: runtime.agents, initPresets: runtime.initPresets, backfills: runtime.backfills, backfillMaterializers: runtime.backfillMaterializers, @@ -238,6 +241,11 @@ function createPhase2SkillRegistry() { if (!skill || typeof skill.name !== 'string' || skill.name.length === 0) { throw new TypeError('skills.register: name is required') } + // @ref LLP 0003#principle [constrained-by] — name is interpolated into + // `/`; reject traversal before it reaches the filesystem. + if (!isSafeContributionName(skill.name)) { + throw new TypeError(`skills.register '${skill.name}': name must be a safe basename (no '/', '\\\\', '..', or absolute path)`) + } if (typeof skill.plugin !== 'string' || skill.plugin.length === 0) { throw new TypeError(`skills.register '${skill.name}': plugin is required`) } @@ -259,6 +267,48 @@ function createPhase2SkillRegistry() { } } +/** + * Agent registry. Stores subagent contributions from client-adapter + * plugins so `hyp agents install` (and the walkthrough finale) can + * enumerate what each plugin wants materialized into the per-client + * agent directories. Mirrors the skill registry, but each contribution + * points at a single markdown definition file rather than a directory. + * + * @returns {AgentRegistry} + */ +function createAgentRegistry() { + /** @type {AgentContribution[]} */ + const items = [] + return { + register(agent) { + if (!agent || typeof agent.name !== 'string' || agent.name.length === 0) { + throw new TypeError('agents.register: name is required') + } + // @ref LLP 0003#principle [constrained-by] — name is interpolated into + // `/.md`; reject traversal before it reaches the filesystem. + if (!isSafeContributionName(agent.name)) { + throw new TypeError(`agents.register '${agent.name}': name must be a safe basename (no '/', '\\\\', '..', or absolute path)`) + } + if (typeof agent.plugin !== 'string' || agent.plugin.length === 0) { + throw new TypeError(`agents.register '${agent.name}': plugin is required`) + } + if (!Array.isArray(agent.clients) || agent.clients.length === 0) { + throw new TypeError(`agents.register '${agent.name}': clients must be a non-empty array`) + } + if (typeof agent.sourceFile !== 'string' || agent.sourceFile.length === 0) { + throw new TypeError(`agents.register '${agent.name}': sourceFile is required`) + } + items.push({ + name: agent.name, + plugin: agent.plugin, + clients: [...agent.clients], + sourceFile: agent.sourceFile, + }) + }, + list() { return items.slice() }, + } +} + /** * Init-preset registry. Plugins contribute presets via * `ctx.initPresets.register({ name, plugin, summary, run })` during diff --git a/src/core/runtime/contribution_names.js b/src/core/runtime/contribution_names.js new file mode 100644 index 0000000..8ad995b --- /dev/null +++ b/src/core/runtime/contribution_names.js @@ -0,0 +1,50 @@ +// @ts-check + +import path from 'node:path' + +/** + * Skill and agent names are interpolated into filesystem destinations + * (`/` and `/.md`). A plugin that + * registers a name containing path separators, `..`, or an absolute + * prefix could otherwise steer `install` to write outside the intended + * client directory. This module centralizes the two guards that keep + * those writes contained. + * + * @ref LLP 0003#principle — names cross the core/plugin trust boundary, + * so core validates them rather than trusting plugins. + */ + +/** + * True when `name` is a single, safe path segment: non-empty, not `.` or + * `..`, with no path separators, null bytes, or absolute prefix. Such a + * name always stays within whatever directory it is joined to. + * + * @param {unknown} name + * @returns {boolean} + */ +export function isSafeContributionName(name) { + if (typeof name !== 'string' || name.length === 0) return false + if (name === '.' || name === '..') return false + if (name.includes('\0')) return false + if (name.includes('/') || name.includes('\\')) return false + if (path.isAbsolute(name)) return false + return path.basename(name) === name +} + +/** + * True when `dest` resolves to a path at or beneath `baseDir`. Defense in + * depth for the install sites: even if a name or a manifest-supplied + * directory slipped a traversal segment past registration, the copy is + * skipped unless the final destination stays under the intended base. + * + * @param {string} dest + * @param {string} baseDir + * @returns {boolean} + */ +export function isWithinDir(dest, baseDir) { + const resolvedBase = path.resolve(baseDir) + const resolvedDest = path.resolve(dest) + if (resolvedDest === resolvedBase) return true + const rel = path.relative(resolvedBase, resolvedDest) + return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel) +} diff --git a/test/core/agents-install.test.js b/test/core/agents-install.test.js new file mode 100644 index 0000000..ab5b9ae --- /dev/null +++ b/test/core/agents-install.test.js @@ -0,0 +1,208 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { registerCoreCommands } from '../../src/core/cli/core_commands.js' +import { dispatch } from '../../src/core/cli/dispatch.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' +import { createKernelRuntime } from '../../src/core/runtime/activation.js' + +function agentsKernelAndRegistry() { + const registry = createCommandRegistry() + registerCoreCommands(registry) + const kernel = createKernelRuntime({ commandRegistry: registry }) + return { kernel, registry } +} + +test('agents.register validates contribution shape', () => { + const { kernel } = agentsKernelAndRegistry() + + assert.throws( + () => kernel.agents.register(/** @type {any} */ ({})), + /name is required/ + ) + assert.throws( + () => kernel.agents.register(/** @type {any} */ ({ name: 'a' })), + /plugin is required/ + ) + assert.throws( + () => kernel.agents.register(/** @type {any} */ ({ name: 'a', plugin: 'p', clients: [] })), + /clients must be a non-empty array/ + ) + assert.throws( + () => kernel.agents.register(/** @type {any} */ ({ name: 'a', plugin: 'p', clients: ['claude'] })), + /sourceFile is required/ + ) + + kernel.agents.register({ + name: 'a', + plugin: /** @type {any} */ ('p'), + clients: ['claude'], + sourceFile: '/abs/a.md', + }) + assert.equal(kernel.agents.list().length, 1) + assert.deepEqual(kernel.agents.list()[0], { + name: 'a', + plugin: 'p', + clients: ['claude'], + sourceFile: '/abs/a.md', + }) +}) + +test('agents.register rejects path-traversal names', () => { + const { kernel } = agentsKernelAndRegistry() + + for (const name of ['../evil', '../../etc/cron.d/x', 'a/b', '/abs', '..', '.']) { + assert.throws( + () => kernel.agents.register(/** @type {any} */ ({ + name, + plugin: 'p', + clients: ['claude'], + sourceFile: '/abs/a.md', + })), + /name must be a safe basename/, + `expected ${JSON.stringify(name)} to be rejected` + ) + } + assert.equal(kernel.agents.list().length, 0) +}) + +test('skills.register rejects path-traversal names', () => { + const { kernel } = agentsKernelAndRegistry() + + for (const name of ['../evil', 'a/b', '/abs', '..']) { + assert.throws( + () => kernel.skills.register(/** @type {any} */ ({ + name, + plugin: 'p', + clients: ['claude'], + sourceDir: '/abs/skill', + })), + /name must be a safe basename/, + `expected ${JSON.stringify(name)} to be rejected` + ) + } + assert.equal(kernel.skills.list().length, 0) +}) + +test('hyp agents install copies registered agent files into the client agent dir', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-agents-')) + const sourceFile = path.join(home, 'src-agent.md') + await fs.writeFile(sourceFile, '---\nname: test-analyst\n---\nbody\n', 'utf8') + + const { kernel, registry } = agentsKernelAndRegistry() + kernel.agents.register({ + name: 'test-analyst', + plugin: /** @type {any} */ ('@hypaware/claude'), + clients: ['claude'], + sourceFile, + }) + + const stdout = makeBuf() + const stderr = makeBuf() + const code = await dispatch(['agents', 'install'], { + stdout, + stderr, + env: { ...process.env, HOME: home }, + registry, + kernel, + }) + + assert.equal(code, 0) + const dest = path.join(home, '.claude', 'agents', 'test-analyst.md') + const installed = await fs.readFile(dest, 'utf8') + assert.equal(installed, '---\nname: test-analyst\n---\nbody\n') + assert.match(stdout.text(), /installed agent 'test-analyst'/) + assert.match(stdout.text(), /installed 1 agent copy/) +}) + +test('hyp agents install warns when the target client has no agent dir', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-agents-')) + const sourceFile = path.join(home, 'src-agent.md') + await fs.writeFile(sourceFile, 'body\n', 'utf8') + + const { kernel, registry } = agentsKernelAndRegistry() + kernel.agents.register({ + name: 'test-analyst', + plugin: /** @type {any} */ ('@hypaware/codex'), + clients: ['codex'], + sourceFile, + }) + + const stdout = makeBuf() + const stderr = makeBuf() + const code = await dispatch(['agents', 'install'], { + stdout, + stderr, + env: { ...process.env, HOME: home }, + registry, + kernel, + }) + + assert.equal(code, 0) + assert.match(stderr.text(), /without an agent directory/) + assert.match(stdout.text(), /installed 0 agent copy/) + await assert.rejects(fs.access(path.join(home, '.codex', 'agents'))) +}) + +test('hyp agents install respects --client filtering', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'hypaware-agents-')) + const sourceFile = path.join(home, 'src-agent.md') + await fs.writeFile(sourceFile, 'body\n', 'utf8') + + const { kernel, registry } = agentsKernelAndRegistry() + kernel.agents.register({ + name: 'test-analyst', + plugin: /** @type {any} */ ('@hypaware/claude'), + clients: ['claude'], + sourceFile, + }) + + const stdout = makeBuf() + const stderr = makeBuf() + const code = await dispatch(['agents', 'install', '--client', 'codex'], { + stdout, + stderr, + env: { ...process.env, HOME: home }, + registry, + kernel, + }) + + assert.equal(code, 0) + assert.match(stdout.text(), /installed 0 agent copy/) + await assert.rejects(fs.access(path.join(home, '.claude', 'agents', 'test-analyst.md'))) +}) + +test('bundled @hypaware/claude manifest declares the hypaware-analyst agent', async () => { + const manifestPath = path.resolve( + 'hypaware-core/plugins-workspace/claude/hypaware.plugin.json' + ) + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) + assert.equal(manifest.contributes.client.agent_dir, '.claude/agents') + assert.deepEqual(manifest.contributes.agents, [ + { name: 'hypaware-analyst', clients: ['claude'] }, + ]) + + const agentFile = path.resolve( + 'hypaware-core/plugins-workspace/claude/agents/hypaware-analyst.md' + ) + const body = await fs.readFile(agentFile, 'utf8') + assert.match(body, /^---\nname: hypaware-analyst\n/) +}) + +function makeBuf() { + let value = '' + return { + write(chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} diff --git a/test/core/config.test.js b/test/core/config.test.js index 69e40c0..d9c9629 100644 --- a/test/core/config.test.js +++ b/test/core/config.test.js @@ -291,6 +291,7 @@ test('buildPluginCatalog extracts client descriptors from manifests', async () = const claude = catalog.clientDescriptors.get('claude') assert.equal(claude?.plugin, '@hypaware/claude') assert.equal(claude?.skillDir, '.claude/skills') + assert.equal(claude?.agentDir, '.claude/agents') assert.equal(claude?.attachProbe?.format, 'json') assert.equal(claude?.attachProbe?.marker_key, '_hypaware') assert.deepEqual(claude?.requiredUpstreams, ['anthropic']) @@ -299,6 +300,7 @@ test('buildPluginCatalog extracts client descriptors from manifests', async () = const codex = catalog.clientDescriptors.get('codex') assert.equal(codex?.plugin, '@hypaware/codex') assert.equal(codex?.skillDir, '.codex/skills') + assert.equal(codex?.agentDir, undefined) assert.equal(codex?.attachProbe?.format, 'toml') assert.deepEqual(codex?.requiredUpstreams, ['openai', 'chatgpt']) }) diff --git a/test/core/contribution-names.test.js b/test/core/contribution-names.test.js new file mode 100644 index 0000000..51b2ed8 --- /dev/null +++ b/test/core/contribution-names.test.js @@ -0,0 +1,54 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' + +import { isSafeContributionName, isWithinDir } from '../../src/core/runtime/contribution_names.js' + +test('isSafeContributionName accepts plain basenames', () => { + for (const name of ['hypaware-analyst', 'test-analyst', 'foo.bar', 'a', 'A_1', '..foo']) { + assert.equal(isSafeContributionName(name), true, `expected ${name} to be safe`) + } +}) + +test('isSafeContributionName rejects traversal and separators', () => { + for (const name of [ + '', + '.', + '..', + '../evil', + '../../etc/cron.d/x', + 'a/b', + 'a\\b', + '/abs', + '/etc/passwd', + 'foo/../bar', + 'with\0null', + ]) { + assert.equal(isSafeContributionName(name), false, `expected ${JSON.stringify(name)} to be unsafe`) + } +}) + +test('isSafeContributionName rejects non-strings', () => { + for (const v of [undefined, null, 42, {}, []]) { + assert.equal(isSafeContributionName(/** @type {any} */ (v)), false) + } +}) + +test('isWithinDir accepts the base dir and paths beneath it', () => { + const base = '/home/u/.claude/agents' + assert.equal(isWithinDir(base, base), true) + assert.equal(isWithinDir(path.join(base, 'analyst.md'), base), true) + assert.equal(isWithinDir(path.join(base, 'nested', 'x.md'), base), true) +}) + +test('isWithinDir rejects paths that escape the base dir', () => { + const base = '/home/u/.claude/agents' + // The shape a traversal name would collapse to once joined. + assert.equal(isWithinDir(path.join(base, '..', 'evil.md'), base), false) + assert.equal(isWithinDir('/home/u/.claude/evil.md', base), false) + assert.equal(isWithinDir('/etc/passwd', base), false) + // A sibling sharing a name prefix must not be treated as contained. + assert.equal(isWithinDir('/home/u/.claude/agents-evil/x.md', base), false) +}) diff --git a/test/core/sink-materialize.test.js b/test/core/sink-materialize.test.js index 3c1b5fc..ff43a1f 100644 --- a/test/core/sink-materialize.test.js +++ b/test/core/sink-materialize.test.js @@ -128,6 +128,7 @@ function makeRuntime(overrides = {}) { storage: createQueryStorageService({ cacheRoot }), cacheRoot, skills: { register() {}, list() { return [] } }, + agents: { register() {}, list() { return [] } }, initPresets: { register() {}, get() { return undefined }, list() { return [] } }, backfills: createBackfillRegistry(), backfillMaterializers: createBackfillMaterializerRegistry(), @@ -157,6 +158,7 @@ function registerActivationContext(pluginName, runtime) { query: runtime.query, storage: runtime.storage, skills: runtime.skills, + agents: runtime.agents, initPresets: runtime.initPresets, backfills: runtime.backfills, backfillMaterializers: runtime.backfillMaterializers,