Skip to content
Merged
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ no new ADR), **the first additive lane done**; and the agent-first `relavium cha
dedicated, security-reviewed follow-up, so 2.M's REPL shipped without it); and the rest of the agent-first chat
family landed — `relavium chat-resume` (2.N), `chat-list` (2.O), `chat-export` + the in-REPL `/export` (2.P),
and `chat --json` + one-shot `agent run` (with `--fixture` cassette replay) (2.Q) — **PR #55, no new ADR**,
completing the agent-first CLI lane. The next pickup is 2.R
(the inbound MCP client, ADR-0034 — off the M3 critical path and the Phase-3 go/no-go). For live status, per-PR history,
milestone dates, and open obligations, see the canonical home
completing the agent-first CLI lane; and **2.R (the inbound MCP client) is ✅ Done** — the `@relavium/mcp`
foundation (the SDK-fenced package, the dependency-free JSON-Schema→Zod compiler, the fail-loud connect-all
manager) landed **PR #56**, and the host wiring (chat + run + one-shot `agent run`), the network transports
(`http`/`sse`/`websocket`) behind the SSRF floor, named secrets via the isolated `mcp-secret:*` keychain
namespace, the by-name `ref` form, and the real-spawn e2e landed **PR #57 (2026-06-27)** — behind
[ADR-0034](docs/decisions/0034-mcp-client-sdk-dependency.md) + [ADR-0052](docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md)
+ [ADR-0053](docs/decisions/0053-mcp-network-transport-egress-security.md). 2.R was off the M3 critical path and
the Phase-3 go/no-go, so it adds capability without gating. The next pickup is **2.J** (the last in-phase additive
Comment on lines +70 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stray + before the ADR-0053 link.

This looks like an unstripped diff marker and will render awkwardly in the rendered guide.

♻️ Proposed fix
- [ADR-0052](docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) + [ADR-0053](docs/decisions/0053-mcp-network-transport-egress-security.md). 2.R was off the M3 critical path and
+ [ADR-0052](docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) and [ADR-0053](docs/decisions/0053-mcp-network-transport-egress-security.md). 2.R was off the M3 critical path and
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[ADR-0034](docs/decisions/0034-mcp-client-sdk-dependency.md) + [ADR-0052](docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md)
+ [ADR-0053](docs/decisions/0053-mcp-network-transport-egress-security.md). 2.R was off the M3 critical path and
the Phase-3 go/no-go, so it adds capability without gating. The next pickup is **2.J** (the last in-phase additive
[ADR-0034](docs/decisions/0034-mcp-client-sdk-dependency.md) + [ADR-0052](docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) and [ADR-0053](docs/decisions/0053-mcp-network-transport-egress-security.md). 2.R was off the M3 critical path and
the Phase-3 go/no-go, so it adds capability without gating. The next pickup is **2.J** (the last in-phase additive
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 70 - 72, The rendered guide contains a stray diff
marker before the ADR-0053 reference, so update the relevant paragraph in
CLAUDE.md to remove the leading plus sign and keep the ADR links as plain prose.
Locate the line that mentions ADR-0034, ADR-0052, and ADR-0053, and ensure the
text reads cleanly without any leftover patch notation.

lane). For live status, per-PR history, milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the
public overview.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ prompt + out-of-band `relavium gate` resume, the read commands (`list` / `logs`
over durable history, and the published, cross-OS-installable `npm i -g relavium` binary (packaging &
install verification) have landed (milestone **M3** reached; with packaging shipped, all seven Phase-3
go/no-go exit criteria now hold). The first additive lanes have since landed too — media host-wiring
(a generative media-output fixture runs end-to-end on the CLI) and the full agent-first chat family: the
(a generative media-output fixture runs end-to-end on the CLI); the full agent-first chat family: the
`relavium chat` REPL plus session resume / list / export, a headless `chat --json` event stream, and a
one-shot `agent run` with deterministic offline `--fixture` replay — the first user-facing `AgentSession`
surface. For live status and the full roadmap, see
one-shot `agent run` with deterministic offline `--fixture` replay (the first user-facing `AgentSession`
surface); and the **inbound MCP client** — agents consume external MCP servers' tools over stdio + the
`http`/`sse`/`websocket` network transports (behind an SSRF floor and isolated keychain-resolved secrets),
proven by a real-spawn end-to-end test. For live status and the full roadmap, see
[docs/roadmap/current.md](docs/roadmap/current.md) and the
[roadmap](docs/roadmap/README.md).

Expand Down
364 changes: 364 additions & 0 deletions apps/cli/src/authoring/authoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';

import {
AgentParseError,
MAX_SOURCE_CHARS,
WorkflowParseError,
parseAgent,
parseWorkflow,
serializeAgent,
serializeWorkflow,
type AgentDefinition,
type WorkflowDefinition,
} from '@relavium/core';
import { SCHEMA_VERSION } from '@relavium/shared';

import { findProjectConfigDir } from '../config/paths.js';
import { CliError } from '../process/errors.js';
import { discoverCatalog, type CatalogKind } from '../workflows/catalog.js';

/**
* The shared core of the **YAML-authoring lifecycle** (2.J `create` / `import` / `export`). It is
* **surface-agnostic** — it only reads/writes git-native YAML under a project `.relavium/`, never the keychain
* or run state — and reuses the SAME strict core parsers (`parseWorkflow` / `parseAgent`) the `run`/`list` paths
* use, so an authored file is exactly as valid as one a run would accept. A re-serialize from the validated AST
* (`serializeWorkflow` / `serializeAgent`) is the share-safety guarantee: a provider key is never in the file by
* construction (there is no schema field that holds a key value — keys live in the OS keychain), an MCP `env`
* secret is referenced via a `{{secrets.*}}` placeholder by convention, and re-serializing faithfully drops any
* authored comments where a stray secret might otherwise hide (it preserves placeholders; it does not scrub).
*/

export type AuthoredKind = 'workflow' | 'agent';

/** A parsed, validated authored document + its identity (`slug` = the in-file `id`). */
export type ParsedAuthored =
| { readonly kind: 'workflow'; readonly slug: string; readonly definition: WorkflowDefinition }
| { readonly kind: 'agent'; readonly slug: string; readonly definition: AgentDefinition };

/** Per-kind catalog subdir (also the `discoverCatalog` kind) and canonical filename suffix. */
const KIND_DIR: Record<AuthoredKind, CatalogKind> = { workflow: 'workflows', agent: 'agents' };
const KIND_SUFFIX: Record<AuthoredKind, string> = {
workflow: '.relavium.yaml',
agent: '.agent.yaml',
};

/** The project `.relavium/` to author into: the nearest one walking up from `cwd`, else `<cwd>/.relavium`. */
export function resolveProjectConfigDir(cwd: string): string {
return findProjectConfigDir(cwd) ?? join(cwd, '.relavium');
}

/** The canonical filename for an authored doc — `<slug>.relavium.yaml` (workflow) / `<slug>.agent.yaml` (agent). */
export function authoredFileName(kind: AuthoredKind, slug: string): string {
return `${slug}${KIND_SUFFIX[kind]}`;
}

/** The canonical catalog path for an authored doc: `<projectConfigDir>/<workflows|agents>/<slug>.<suffix>`. */
export function catalogPath(projectConfigDir: string, kind: AuthoredKind, slug: string): string {
return join(projectConfigDir, KIND_DIR[kind], authoredFileName(kind, slug));
}

/** Re-serialize a parsed authored doc to canonical, share-safe YAML (drops comments; preserves placeholders). */
export function serializeAuthored(doc: ParsedAuthored): string {
return doc.kind === 'workflow'
? serializeWorkflow(doc.definition)
: serializeAgent(doc.definition);
}

/**
* Detect a YAML document's kind and parse-validate it into a {@link ParsedAuthored}. A malformed/invalid doc is
* a typed, exit-2 {@link CliError} (the parser's message is field-named + secret-free by contract). The
* `fileName` extension is a HINT that picks the parser, so a clearly-`.agent.yaml` / `.relavium.yaml` file
* reports the right kind's error; a bare `.yaml`/`.yml` is SNIFFED — workflow first (its schema requires a
* top-level `workflow:`), then agent — and a doc that is neither surfaces both typed reasons.
*/
export function detectAndParse(yaml: string, source: string, fileName: string): ParsedAuthored {
const lower = fileName.toLowerCase();
if (lower.endsWith('.agent.yaml') || lower.endsWith('.agent.yml')) {
return parseAsAgent(yaml, source);
}
if (lower.endsWith('.relavium.yaml') || lower.endsWith('.relavium.yml')) {
return parseAsWorkflow(yaml, source);
}
// Bare .yaml/.yml — sniff. A workflow has a top-level `workflow:`; an agent does not, so try workflow first.
let workflowError: WorkflowParseError;
try {
return asWorkflow(parseWorkflow(yaml, { source }));
} catch (err) {
if (!(err instanceof WorkflowParseError)) throw err;
workflowError = err;
}
try {
return asAgent(parseAgent(yaml, { source }));
} catch (err) {
if (!(err instanceof AgentParseError)) throw err;
throw new CliError(
'invalid_invocation',
`${source} is not a valid workflow (${workflowError.message}) or agent (${err.message}).`,
);
}
}

function parseAsWorkflow(yaml: string, source: string): ParsedAuthored {
try {
return asWorkflow(parseWorkflow(yaml, { source }));
} catch (err) {
if (err instanceof WorkflowParseError) {
throw new CliError('invalid_invocation', err.message, { cause: err });
}
throw err;
}
}

function parseAsAgent(yaml: string, source: string): ParsedAuthored {
try {
return asAgent(parseAgent(yaml, { source }));
} catch (err) {
if (err instanceof AgentParseError) {
throw new CliError('invalid_invocation', err.message, { cause: err });
}
throw err;
}
}

function asWorkflow(definition: WorkflowDefinition): ParsedAuthored {
return { kind: 'workflow', slug: definition.workflow.id, definition };
}
function asAgent(definition: AgentDefinition): ParsedAuthored {
return { kind: 'agent', slug: definition.id, definition };
}

/** The answers a `create` wizard gathers (the surface-agnostic core; the clack TTY layer is a separate module). */
export interface CreateSpec {
readonly kind: AuthoredKind;
readonly name: string;
readonly provider: AgentDefinition['provider'];
readonly model: string;
readonly systemPrompt: string;
readonly tools: readonly string[];
}

/**
* The interactive `create` wizard seam — gather a {@link CreateSpec}, or `null` when the user cancels the prompt
* itself (Ctrl-C / ESC). The `@clack/prompts`-backed implementation lives in its own module (the ONLY place the
* prompt library is imported, mirroring the gate prompter + the ink renderer split); tests inject a fake.
*/
export interface CreatePrompter {
gather(): Promise<CreateSpec | null>;
}

/**
* Derive a kebab-case id from a human name — lowercase, ASCII-alphanumeric runs joined by single dashes (the
* `kebabIdSchema` charset). A name with no usable `[a-z0-9]` (e.g. only punctuation / non-ASCII) yields an empty
* slug, which is a clean exit-2 {@link CliError} (the name is NOT echoed — it is arbitrary user input).
*/
export function toSlug(name: string): string {
const slug = name
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((segment) => segment.length > 0)
.join('-');
if (slug.length === 0) {
throw new CliError(
'invalid_invocation',
'the name needs at least one ASCII letter or digit to form an id.',
);
}
return slug;
}

/**
* Build a new authored document from a {@link CreateSpec} and VALIDATE it by round-tripping through the strict
* parser — a bad model/provider/system_prompt (or a slug the schema rejects) surfaces as the SAME typed exit-2
* {@link CliError} a `run` would raise. A `workflow` wraps the agent in a minimal `input → agent → output`
* scaffold (the inline agent, a single agent node, the two edges). Returns the validated {@link ParsedAuthored}.
*/
export function buildAuthored(spec: CreateSpec): ParsedAuthored {
const id = toSlug(spec.name);
const agent: AgentDefinition = {
id,
...(spec.name === id ? {} : { name: spec.name }),
provider: spec.provider,
model: spec.model,
system_prompt: spec.systemPrompt,
...(spec.tools.length === 0 ? {} : { tools: [...spec.tools] }),
};
if (spec.kind === 'agent') {
return detectAndParse(serializeAgent(agent), `<new agent>`, '.agent.yaml');
}
const definition: WorkflowDefinition = {
schema_version: SCHEMA_VERSION,
workflow: {
id,
...(agent.name === undefined ? {} : { name: agent.name }),
agents: [agent],
nodes: [
{ id: 'input', type: 'input' },
{ id: 'main', type: 'agent', agent_ref: id },
{ id: 'output', type: 'output' },
],
edges: [
{ from: 'input', to: 'main' },
{ from: 'main', to: 'output' },
],
},
};
return detectAndParse(serializeWorkflow(definition), `<new workflow>`, '.relavium.yaml');
}

/**
* Read a YAML file as UTF-8 for authoring, with the SAME regular-file + size guards the catalog scan uses (a
* crafted symlink to a FIFO/char-device would otherwise block `readFileSync` indefinitely). A missing /
* unreadable file, a non-regular-file target, or one over the source cap is a typed, exit-2 {@link CliError}
* whose message is path-free of the absolute path (never echo a raw fs error — error-handling.md).
*/
export function readAuthoredFile(path: string, displayPath: string): string {
let stats: ReturnType<typeof statSync>;
try {
stats = statSync(path);
} catch (err) {
if (errnoCode(err) === 'ENOENT') {
throw new CliError('invalid_invocation', `no file at ${displayPath}`);
}
throw new CliError('invalid_invocation', `could not read ${displayPath}`);
}
if (!stats.isFile()) {
throw new CliError('invalid_invocation', `${displayPath} is not a regular file`);
}
// The parser's cap is a CHARACTER count; stat.size is BYTES (≤ 4 bytes/UTF-8 char), so `chars * 4` is a byte
// ceiling that never false-rejects a within-limit file while bailing before slurping a huge file into memory.
if (stats.size > MAX_SOURCE_CHARS * 4) {
throw new CliError('invalid_invocation', `${displayPath} exceeds the size limit`);
}
try {
return readFileSync(path, 'utf8');
} catch {
throw new CliError('invalid_invocation', `could not read ${displayPath}`);
}
}

/**
* Atomically write an authored YAML file, creating parent dirs. Without `force`, an existing target is a clean
* exit-2 fault — `wx` fails `EEXIST` so there is no TOCTOU window between a separate existence check and the
* write; `w` truncates/overwrites under `force`. A non-file target (`EISDIR`/`ENOTDIR`) is also exit 2; other
* write faults (permissions, disk) propagate. User-facing messages name only `displayPath` (a cwd-relative path
* the caller resolves), never the absolute `path` — mirroring {@link readAuthoredFile} (error-handling.md).
*/
export function writeAuthoredFile(
path: string,
displayPath: string,
content: string,
force: boolean,
): void {
try {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, { encoding: 'utf8', flag: force ? 'w' : 'wx' });
Comment on lines +247 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file map =="
git ls-files apps/cli/src/authoring | sed 's#^`#-` #'

echo
echo "== outline =="
ast-grep outline apps/cli/src/authoring/authoring.ts --view expanded || true

echo
echo "== relevant slice =="
sed -n '200,320p' apps/cli/src/authoring/authoring.ts

echo
echo "== usages of writeAuthoredFile =="
rg -n "writeAuthoredFile\(" apps/cli/src -A 3 -B 3

echo
echo "== symlink-related helpers in authoring area =="
rg -n "symlink|lstat|statSync|openSync|O_NOFOLLOW|nofollow|force" apps/cli/src/authoring apps/cli/src -A 2 -B 2

Repository: HodeTech/Relavium

Length of output: 50373


Reject symlinked write targets. writeFileSync(..., { flag: 'w' }) follows an existing symlink, so a .relavium/... link can redirect create/import/export --force outside the catalog. Open with O_NOFOLLOW (or equivalent) and treat symlink targets as exit 2.

Source: Learnings

} catch (err) {
const code = errnoCode(err);
if (code === 'EEXIST') {
throw new CliError(
'invalid_invocation',
`${displayPath} already exists — pass --force to overwrite`,
{ cause: err },
);
}
if (code === 'EISDIR' || code === 'ENOTDIR') {
throw new CliError(
'invalid_invocation',
`cannot write ${displayPath}: the target path is not a file`,
{ cause: err },
);
}
throw err;
}
}

/**
* Locate an authored file by its in-file `id` across BOTH catalogs (the `relavium export <id>` resolution).
* Returns the matched kind + the absolute path. A missing id is a clean exit-2 fault; an id that names BOTH a
* workflow and an agent is ambiguous (exit 2) — disambiguate by renaming one. Only VALID catalog entries match
* (an unparseable file's slug is a filename FALLBACK, never a real id).
*/
export function resolveById(opts: { projectConfigDir: string; cwd: string; id: string }): {
kind: AuthoredKind;
path: string;
} {
const matches: { kind: AuthoredKind; path: string }[] = [];
for (const kind of ['workflow', 'agent'] as const) {
const entry = discoverCatalog({
projectConfigDir: opts.projectConfigDir,
cwd: opts.cwd,
kind: KIND_DIR[kind],
}).find((e) => e.valid && e.slug === opts.id);
if (entry !== undefined) {
matches.push({ kind, path: resolve(opts.cwd, entry.path) });
}
}
if (matches.length === 0) {
throw new CliError(
'invalid_invocation',
`no workflow or agent with id '${opts.id}' in this project.`,
);
}
if (matches.length > 1) {
throw new CliError(
'invalid_invocation',
`'${opts.id}' names both a workflow and an agent — rename one to disambiguate.`,
);
}
return matches[0]!;
}

/** True if a VALID catalog entry of `kind` already has this `slug` (a single-catalog membership check). */
export function slugExists(opts: {
projectConfigDir: string;
cwd: string;
kind: AuthoredKind;
slug: string;
}): boolean {
return discoverCatalog({
projectConfigDir: opts.projectConfigDir,
cwd: opts.cwd,
kind: KIND_DIR[opts.kind],
}).some((e) => e.valid && e.slug === opts.slug);
}

/**
* The id-uniqueness guard shared by `create` + `import`. An id is a PROJECT-GLOBAL handle — `resolveById` /
* `export <id>` resolve a bare id across BOTH catalogs — so a slug must be unique across agents AND workflows,
* not merely within one kind. Throws a typed exit-2 {@link CliError}, or returns cleanly if the write may go:
* - the slug already exists as the OTHER kind → always reject: a `--force` overwrite of *this* kind would leave
* the other file in place and `export <id>` permanently ambiguous, so the user must rename/remove one;
* - the slug exists as the SAME kind → reject unless `force` (an in-place, unambiguous overwrite).
*/
export function assertSlugAvailable(opts: {
projectConfigDir: string;
cwd: string;
kind: AuthoredKind;
slug: string;
force: boolean;
}): void {
const { projectConfigDir, cwd, kind, slug, force } = opts;
const otherKind: AuthoredKind = kind === 'agent' ? 'workflow' : 'agent';
if (slugExists({ projectConfigDir, cwd, kind: otherKind, slug })) {
throw new CliError(
'invalid_invocation',
`'${slug}' already exists as a ${otherKind} in this project — an id must be unique across agents and workflows; rename one.`,
);
}
if (!force && slugExists({ projectConfigDir, cwd, kind, slug })) {
throw new CliError(
'invalid_invocation',
`${kind} '${slug}' already exists in this project — pass --force to overwrite.`,
);
}
}

/** The `errno` code of a Node fs error (`ENOENT`, `EACCES`, …), or `undefined` if it is not one. */
function errnoCode(err: unknown): string | undefined {
if (typeof err === 'object' && err !== null && 'code' in err) {
const code: unknown = err.code;
return typeof code === 'string' ? code : undefined;
}
return undefined;
}
Loading
Loading