Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,15 @@
"Bash(head:*)",
"Bash(tail:*)",
"Bash(wc:*)",
"Bash(tree:*)",
"Read(//Users/dev/Documents/Projects/Agent-Organizer/**)"
"Bash(tree:*)"
],
"deny": [
"Bash(sudo:*)",
"Bash(rm -rf:*)",
"Bash(git push:*)",
"Read(.env)",
"Read(.env.*)",
"Read(**/*.pem)",
"Read(**/secrets/**)"
"Read(**/*.pem)"
]
}
}
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ entry point (multi-turn sessions, persistence, export-to-workflow) are shipped.
**Phase 2 (CLI) is underway and milestone M3 is reached** — the CLI skeleton (2.A) and config
resolution (2.B) landed (PR #40), `relavium run` is wired to the engine
(2.D, the M3 keystone — PR #41), the `--json` CI machine-output contract
landed (2.F — PR #42, ADR-0049), and the engine regression harness (2.K — PR #43)
completes M3. The next pickup is 2.H (durable run history). For live status, per-PR history,
landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43)
completes M3, and durable run history landed (2.H — PR #44, ADR-0050). The next pickup is 2.C
(provider/keys). 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ One engine, three modes behind the one `LLMProvider` seam:
local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint/resume,
multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is
underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its
`--json` CI machine-output contract, and the engine regression harness have landed (milestone
**M3** reached). For live status and the full roadmap, see
`--json` CI machine-output contract, the engine regression harness, and durable local run history
have landed (milestone **M3** reached). 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
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run"
},
"dependencies": {
"@napi-rs/keyring": "catalog:",
"@relavium/core": "workspace:*",
"@relavium/db": "workspace:*",
"@relavium/llm": "workspace:*",
Expand Down
257 changes: 257 additions & 0 deletions apps/cli/src/commands/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { createClient, createProviderStore, runMigrations, type DbClient } from '@relavium/db';
import type { LlmProvider } from '@relavium/llm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
createProviderResolver,
keyHint,
providerKeyEnvVar,
type ProviderResolver,
} from '../engine/providers.js';
import { KeychainUnavailableError, type KeychainStore } from '../secrets/keychain.js';
import { readSecretFromStdin } from '../secrets/read-secret.js';
import { captureIo } from '../test-support.js';
import { runProviderCommand, type ProviderCommandDeps } from './provider.js';

const TS_MS = new Date('2026-06-23T12:00:00.000Z').getTime();
// A fake key built from fragments so no contiguous secret literal exists (Leakwatch convention).
const RAW_KEY = ['sk', 'ant', 'FAKEKEY', '9999'].join('-');

/** A mutable in-memory keychain — the production `@napi-rs/keyring` accessor is never loaded by these tests. */
function memKeychain(
seed: Record<string, string> = {},
): KeychainStore & { map: Map<string, string> } {
const map = new Map(Object.entries(seed));
return {
map,
get: (account) => map.get(account) ?? null,
set: (account, secret) => {
map.set(account, secret);
},
delete: (account) => map.delete(account),
};
}

/** A stub resolver for `provider test` — `generate` resolves or rejects; `keyFor` returns a fixed key. */
function stubResolver(generate: LlmProvider['generate']): ProviderResolver {
const provider: LlmProvider = {
id: 'anthropic',
generate,
stream: () => {
throw new Error('stream not used in provider test');
},
// `provider test` only calls `generate`; `supports` is never read here. A real CapabilityFlags fixture
// would have to satisfy the schema's `vision === media.input.image` refine (+ the nested media shape) and
// would couple this test to that evolving seam schema for zero assertion value — hence a bare stub cast.
supports: {} as LlmProvider['supports'],

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find explicit casts in this test file.
rg -n --no-heading '\sas\s' apps/cli/src/commands/provider.test.ts

# 2) Locate LlmProvider type/interface declarations in-repo.
rg -n --no-heading 'interface\s+LlmProvider|type\s+LlmProvider' packages apps

# 3) Locate catch blocks and casted error assertions in this file.
rg -n --no-heading 'catch\s*\(|\(\s*caught\s+as\s+Error\s*\)' apps/cli/src/commands/provider.test.ts

Repository: HodeTech/Relavium

Length of output: 1596


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Read the LlmProvider interface definition
echo "=== LlmProvider Interface ==="
sed -n '472,530p' packages/llm/src/types.ts

# 2) Read the test context around the catch block (lines 130-150)
echo -e "\n=== Catch Block Context (lines 130-150) ==="
sed -n '130,150p' apps/cli/src/commands/provider.test.ts

# 3) Read the fixture context (lines 35-50)
echo -e "\n=== Fixture Context (lines 35-50) ==="
sed -n '35,50p' apps/cli/src/commands/provider.test.ts

# 4) Read context around line 67
echo -e "\n=== Context around line 67 ==="
sed -n '60,75p' apps/cli/src/commands/provider.test.ts

# 5) Read context around line 119
echo -e "\n=== Context around line 119 ==="
sed -n '110,130p' apps/cli/src/commands/provider.test.ts

Repository: HodeTech/Relavium

Length of output: 4531


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find CapabilityFlags definition
echo "=== CapabilityFlags Definition ==="
rg -n 'type CapabilityFlags|interface CapabilityFlags' packages llm

# 2) Find LlmResult definition
echo -e "\n=== LlmResult Definition ==="
rg -n 'type LlmResult|interface LlmResult' packages llm

# 3) Check if there are other test fixtures using these types
echo -e "\n=== Other test fixtures for LlmProvider ==="
rg -B2 -A4 'LlmProvider.*=' packages llm --type ts | head -40

Repository: HodeTech/Relavium

Length of output: 457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Read CapabilityFlagsSchema definition
echo "=== CapabilityFlagsSchema Context (lines 220-240) ==="
sed -n '220,240p' packages/llm/src/types.ts

# 2) Search for LlmResult with proper path
echo -e "\n=== LlmResult Definition ==="
rg -n 'type LlmResult|interface LlmResult' --type ts packages/

# 3) Check what fields are actually needed for the test
echo -e "\n=== Checking test assertions on LlmResult ==="
rg -B5 -A5 'generate.*Promise.resolve' apps/cli/src/commands/provider.test.ts | head -30

Repository: HodeTech/Relavium

Length of output: 1111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Read LlmResultSchema definition
echo "=== LlmResultSchema Context (lines 330-350) ==="
sed -n '330,350p' packages/llm/src/types.ts

# 2) Read CapabilityFlagsSchema starting earlier
echo -e "\n=== CapabilityFlagsSchema Full Definition (lines 210-230) ==="
sed -n '210,230p' packages/llm/src/types.ts

# 3) Check what actual assertions are made on generate result in tests
echo -e "\n=== Actual test assertions on generate promise result ==="
rg -B10 'Promise.resolve.*as.*LlmProvider.*generate' apps/cli/src/commands/provider.test.ts | head -50

Repository: HodeTech/Relavium

Length of output: 2993


Remove unsafe as casts from test doubles and error assertions.

Lines 43, 67, 119, and 141-142 bypass strict typing with as casts:

  • Line 43: supports: {} as LlmProvider['supports'] — empty object cannot satisfy CapabilityFlags, which requires tools, streaming, parallelToolCalls, vision, promptCache, reasoning booleans and a complex nested media object with a refine constraint.
  • Lines 67, 119: {} as Awaited<ReturnType<LlmProvider['generate']>> — empty objects bypass LlmResult structure requirements.
  • Lines 141-142: (caught as Error).message — unsafe type assertion on a value declared as unknown. Use a type guard (instanceof Error) or property check instead.

Per coding guidelines, **/*.{ts,tsx} must use strict TypeScript with no unsafe as casts. Create properly typed test fixtures (e.g., Partial<CapabilityFlags>, Partial<LlmResult>) or use type guards for runtime checks.

🧰 Tools
🪛 ESLint

[error] 43-43: Unsafe assignment of an error typed value.

(@typescript-eslint/no-unsafe-assignment)

🤖 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 `@apps/cli/src/commands/provider.test.ts` at line 43, Replace all unsafe `as`
type casts in the test file with proper type guards or partial typed fixtures.
For the `supports` property assignment (line 43), replace `{} as
LlmProvider['supports']` with a `Partial<CapabilityFlags>` that includes the
required boolean properties (tools, streaming, parallelToolCalls, vision,
promptCache, reasoning) and media object structure. For the two instances of `{}
as Awaited<ReturnType<LlmProvider['generate']>>` (lines 67 and 119), replace
them with `Partial<LlmResult>` fixtures containing appropriate test values. For
the error assertions at lines 141-142, replace the `(caught as Error).message`
cast with a proper type guard using `instanceof Error` or a property existence
check before accessing the message property, ensuring the code path only
accesses message when the type is confirmed.

Sources: Coding guidelines, Linters/SAST tools

};
return { resolveProvider: () => provider, keyFor: () => RAW_KEY };
}

describe('relavium provider commands (2.C)', () => {
let client: DbClient;
let deps: (over: Partial<ProviderCommandDeps>) => ProviderCommandDeps;
let io: ReturnType<typeof captureIo>;

beforeEach(() => {
client = createClient(':memory:');
runMigrations(client.db);
io = captureIo();
let next = 0;
const store = createProviderStore(client.db, {
uuid: () => `00000000-0000-4000-8000-${String(++next).padStart(12, '0')}`,
now: () => TS_MS,
});
deps = (over) => ({
io: io.io,
store,
keychain: memKeychain(),
resolver: stubResolver(() =>
Promise.resolve({} as Awaited<ReturnType<LlmProvider['generate']>>),
),
readSecret: () => Promise.resolve(RAW_KEY),
...over,
});
});

afterEach(() => {
client.sqlite.close();
});

it('set-key stores the key in the keychain + only a ref in the db — never the key value', async () => {
const keychain = memKeychain();
const d = deps({ keychain });
const code = await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d);
expect(code).toBe(0);
expect(keychain.get('anthropic:default')).toBe(RAW_KEY); // raw key in the keychain only
const rec = d.store.get('anthropic');
expect(rec?.apiKeyKeychainRef).toBe('anthropic:default'); // db row holds the ref
expect(JSON.stringify(rec)).not.toContain(RAW_KEY); // ...never the key
expect(io.out()).toContain('••••9999'); // a hint, never the full key
expect(io.out()).not.toContain(RAW_KEY);
});

it('list shows registered providers and whether a key is set (no key echoed)', async () => {
const keychain = memKeychain();
const d = deps({ keychain });
await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d);
await runProviderCommand({ action: 'add', name: 'openai' }, d);
io.out(); // (accumulates; assert on the final list call)
await runProviderCommand({ action: 'list' }, d);
const text = io.out();
expect(text).toContain('anthropic');
expect(text).toContain('[key set]');
expect(text).toContain('openai');
expect(text).toContain('[no key]');
expect(text).not.toContain(RAW_KEY);
});

it('remove-key deletes the keychain entry and clears the db ref', async () => {
const keychain = memKeychain();
const d = deps({ keychain });
await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d);
const code = await runProviderCommand({ action: 'remove-key', name: 'anthropic' }, d);
expect(code).toBe(0);
expect(keychain.get('anthropic:default')).toBeNull();
expect(d.store.get('anthropic')?.apiKeyKeychainRef).toBeUndefined();
});

it('test reports success when the provider generate resolves', async () => {
const d = deps({
resolver: stubResolver(() =>
Promise.resolve({} as Awaited<ReturnType<LlmProvider['generate']>>),
),
});
const code = await runProviderCommand({ action: 'test', name: 'anthropic' }, d);
expect(code).toBe(0);
expect(io.out()).toContain('key works');
});

it('test fails cleanly (exit 2) and redacts the key even if the provider error contains it', async () => {
// The provider error embeds the raw key — the CLI must redact it (the key is in scope at the catch).
const d = deps({
resolver: stubResolver(() =>
Promise.reject(new Error(`401 unauthorized: ${RAW_KEY} rejected`)),
),
});
let caught: unknown;
try {
await runProviderCommand({ action: 'test', name: 'anthropic' }, d);
} catch (err) {
caught = err;
}
expect(caught).toMatchObject({ exitCode: 2 });
expect(caught).toBeInstanceOf(Error);
const message = caught instanceof Error ? caught.message : String(caught);
expect(message).not.toContain(RAW_KEY); // redacted...
expect(message).toContain('••••9999'); // ...to the hint
});

it('rejects a non-HTTPS or malformed --base-url (exit 2)', async () => {
// HTTPS-only at store time — a plaintext `http:` endpoint must never be persisted, and a
// non-`http(s)` scheme is rejected outright (matches the routing-time `assertHttpsBaseUrl` gate).
for (const baseUrl of ['http://proxy.example/v1', 'file:///etc/passwd', 'not-a-url']) {
await expect(
runProviderCommand({ action: 'add', name: 'openai', baseUrl }, deps({})),
).rejects.toMatchObject({ exitCode: 2 });
}
});

it('set-key preserves a base URL set by a prior `add` (never clobbers it)', async () => {
const d = deps({});
await runProviderCommand(
{ action: 'add', name: 'openai', baseUrl: 'https://proxy.example/v1' },
d,
);
await runProviderCommand({ action: 'set-key', name: 'openai' }, d);
expect(d.store.get('openai')?.baseUrl).toBe('https://proxy.example/v1'); // not the SDK default
});

it('rejects an unknown provider name (exit 2)', async () => {
await expect(
runProviderCommand({ action: 'add', name: 'bogus' }, deps({})),
).rejects.toMatchObject({ exitCode: 2 });
});

it('maps a keychain-unavailable backend to a clean exit-2 message', async () => {
const keychain: KeychainStore = {
get: () => null,
set: () => {
throw new KeychainUnavailableError('no Secret Service');
},
delete: () => false,
};
await expect(
runProviderCommand({ action: 'set-key', name: 'anthropic' }, deps({ keychain })),
).rejects.toMatchObject({ exitCode: 2 });
});
});

describe('createProviderResolver keyFor — keychain → env var → error (2.C)', () => {
it('prefers the OS keychain', () => {
const keychain = memKeychain({ 'anthropic:default': 'from-keychain' });
const resolver = createProviderResolver(
{ [providerKeyEnvVar('anthropic')]: 'from-env' },
keychain,
);
expect(resolver.keyFor('anthropic')).toBe('from-keychain');
});

it('falls back to the env var when the keychain has no entry', () => {
const resolver = createProviderResolver(
{ [providerKeyEnvVar('openai')]: 'from-env' },
memKeychain(),
);
expect(resolver.keyFor('openai')).toBe('from-env');
});

it('falls through to the env var when the keychain backend is unavailable', () => {
const keychain: KeychainStore = {
get: () => {
throw new KeychainUnavailableError('locked');
},
set: () => undefined,
delete: () => false,
};
const resolver = createProviderResolver(
{ [providerKeyEnvVar('gemini')]: 'from-env' },
keychain,
);
expect(resolver.keyFor('gemini')).toBe('from-env');
});

it('throws a clean invocation error (exit 2) when no source has a key', () => {
const resolver = createProviderResolver({}, memKeychain());
expect(() => resolver.keyFor('deepseek')).toThrowError(/no API key for provider 'deepseek'/);
});
});

describe('keyHint', () => {
it('shows only the last 4 chars, never the full key', () => {
expect(keyHint(RAW_KEY)).toBe('••••9999');
expect(keyHint(RAW_KEY)).not.toContain('sk-ant');
});
it('fully masks a too-short value', () => {
expect(keyHint('abc')).toBe('••••');
});
});

describe('readSecretFromStdin', () => {
it('refuses to read a typed key from an interactive TTY (errors with a pipe hint, exit 2)', async () => {
// Capture the FULL original descriptor so the restore reinstates it exactly (a value-only restore would
// leave a synthetic data property where there may have been an accessor / a different configurability).
const original = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
// A typed secret on an echoing terminal is the failure mode the stdin guard prevents — even if stdout
// is redirected (the guard keys on stdin, not stdout).
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
await expect(readSecretFromStdin()).rejects.toMatchObject({ exitCode: 2 });
} finally {
if (original === undefined) {
delete (process.stdin as { isTTY?: boolean }).isTTY;
} else {
Object.defineProperty(process.stdin, 'isTTY', original);
}
}
});
});
Loading
Loading