mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
* feat+test+docs: D68+D69+D70 — bin/olp-connect + /health.anonymousKey + ADR 0011
Third substantive Phase 4 implementation. 3 D-days bundled per Iron Rule
11 IDR — olp-connect consumes /health.anonymousKey for zero-config
client setup, both governed by ADR 0011's trusted-LAN-only invariant.
## D68 — bin/olp-connect (zero-config client setup)
Ports OCP ocp-connect (721 lines) → OLP olp-connect (564 lines, pure bash).
Bash over Node (per ADR 0010 § Notes) because client machines may lack
recent Node; bash + curl + python3 = max portability.
CLI: `olp-connect <host-ip> [--port PORT] [--key API_KEY] [--no-system-env]
[--dry-run] [--help] [--version]`
Workflow:
1. Connectivity probe (curl /health, 5s timeout, distinguishes TCP
unreachable from auth-required)
2. Auth resolution: --key flag → /health.anonymousKey (D69) → interactive
prompt fallback
3. Smoke test (GET /v1/models with bearer)
4. IDE detection + per-IDE config:
- Claude Code: detect + warn (NOT supported as OLP client per ADR 0010)
- Cline: detect + print manual VSCode-settings snippet
- Continue.dev: detect (extension OR ~/.continue/config.yaml) + write
idempotent models: entry
- Cursor: detect + print snippet + WARNING (per prior-art known
base-URL fragility)
- Aider: detect + write OPENAI_API_BASE + OPENAI_API_KEY to rc files
- OpenClaw: detect + print "install /olp plugin (D71-D73 deliverable)"
5. System-level env: macOS launchctl setenv / Linux ~/.config/environment.d
(so VSCode/Cursor started via Dock inherit)
6. Summary + test command
Idempotent (bracketed `# OLP LAN (added by olp-connect)` ... `# /OLP LAN`
blocks in rc files). --dry-run exercises every state-change site without
modifying anything. Exit 0/1/2 conventions.
Installed via package.json bin so `npx olp-connect` works.
## D69 — /health.anonymousKey + auth.advertise_anonymous_key
server.mjs handleHealth emits OPTIONAL `anonymousKey: "olp_..."` field
when ALL THREE prerequisites hold:
1. config.json auth.advertise_anonymous_key === true
2. config.json auth.allow_anonymous === true (per ADR 0007 § 7)
3. At least one non-revoked key has plaintext_advertise field set
Default-off: field is ABSENT (not null) — preserves v0.3.x /health shape;
existing tests don't regress.
bin/olp-keys.mjs new flags: `keygen --anonymous --advertise` writes the
plaintext into the manifest's optional `plaintext_advertise` field AND
prints a WARNING about disk-storage + /health exposure + ADR 0011
pointer. Owner-tier --advertise rejected at BOTH CLI + lib layers.
Implementation note: reused existing guest tier (no new owner_tier:
'anonymous'); plaintext_advertise is a forward-compat optional manifest
field per ADR 0007 § 4 unknown-fields-allowed convention. Cleaner than
introducing a new tier.
anonymousKey appears in BOTH trimmed AND full /health payloads — the
trimmed payload's purpose is to be readable by anonymous clients so they
can self-bootstrap. Tested.
Startup warns on prereq failure (anonymous_key_advertised_but_denied /
anonymous_key_advertised_but_no_anonymous_key_exists) so the relaxed-
posture failure mode is observable. Graceful-degrade: server still
boots; handleHealth re-checks at request time and silently omits the
field when any prereq fails (defense-in-depth).
## D70 — ADR 0011 (anonymous-key deployment-context limits)
New ADR codifying the trusted-LAN-only invariant.
Trade-off documented: anonymous key advertised via /health = anyone who
can reach the server can read /health and use the key. Acceptable ONLY
when "anyone who can reach the server" ≈ "trusted family on the LAN".
Public-internet deployment = instant compromise.
Soft enforcement: server logs startup warn if BIND_ADDRESS resolves to
a public IP AND advertise_anonymous_key: true. No hard allowlist (TLS-
fronted private networks indistinguishable from public from server's
perspective).
Re-evaluation trigger: any time OLP gains "expose to public internet"
deployment mode (e.g., Cloudflare Tunnel guidance in README), revisit.
References ADR 0007 § 7 (identity classes), ADR 0010 § Phase 4 charter
D68-D70 line, OCP server.mjs:148/1454/1488/1555 (PROXY_ANONYMOUS_KEY
reference).
## Test count
658 → 672 (+14 D68-D70 tests across Suite 34: 5 keys.mjs unit + 6 /health
HTTP integration + 3 CLI integration).
## Scope discipline
NO /v1/messages entry surface (out of Phase 4 per ADR 0010).
NO olp-plugin/ Telegram plugin (D71-D73).
NO docs/integrations/*.md files (D71-D73).
NO CHANGELOG / package.json version bump (Phase 4 close handles versioning;
only package.json bin entry for olp-connect added).
NO new npm deps.
## Authority
- ADR 0010 § Phase 4 D-day plan D68-D70 line
- ADR 0011 (this commit — new ADR)
- ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity
classes) + § 9 (keygen flow) — extended by D69 plaintext_advertise
- OCP ocp-connect /Users/taodeng/ocp/ocp-connect (port reference)
- OCP server.mjs:148, 1454, 1488, 1555 (PROXY_ANONYMOUS_KEY reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 3:
/health.anonymousKey + olp-connect zero-config UX)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D68-D70 reviewer P1 + P2 fold-in — README impact note + listKeys redaction + schema_version note
Reviewer APPROVE WITH MINOR — 0 P0, 1 P1, 2 P2; all three folded in.
P1 — README impact note for new Phase 4 user-visible surfaces.
Per CLAUDE.md release_kit.new_feature_doc_expectations:
- new env / config knob → README § Environment Variables
- new endpoint or response field → README § API Endpoints
- new CLI surface → dedicated §
README now documents:
- /health.anonymousKey optional field (in API Endpoints table) with cross-
ref to ADR 0011 + the three-prereq gate
- streaming.heartbeat_interval_ms config (D61) + auth.advertise_anonymous_key
config (D69) under new "config.json keys introduced at Phase 4" subsection
- Operator CLI surfaces summary: olp / olp-connect / olp-keys keygen
--anonymous --advertise, with cross-refs to ADR 0010 + 0002 Amendment 7
P2-1 — lib/keys.mjs listKeys() now strips plaintext_advertise alongside
token_hash. Callers wanting the advertised plaintext for the /health
publication path MUST go through findAdvertisedKey() — the only sanctioned
read site. Defends against a future caller of listKeys() leaking the
plaintext into logs / HTTP responses / dashboards. Tests still pass
(no in-repo caller of listKeys depends on plaintext_advertise being
present).
P2-2 — ADR 0011 now documents the schema_version-stays-at-1 decision
explicitly. Additive optional fields don't require bump per ADR 0007 § 4,
but a future archaeologist asking "why didn't D69 bump schema_version?"
now has a one-line answer. Same paragraph documents the listKeys()
redaction policy in plain text alongside the manifest-field contract.
672/672 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
299 lines
11 KiB
JavaScript
Executable File
299 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* bin/olp-keys.mjs — OLP key management CLI (Phase 2 / D47)
|
|
*
|
|
* Authority: ADR 0007 § 9 (Bootstrap & recovery — minimal keygen command
|
|
* surface) + § 10 acceptance criterion #9 (bootstrap workflow must be
|
|
* reproducible without manual file editing).
|
|
*
|
|
* Subcommands:
|
|
* keygen create a new OLP key; prints plaintext token to stdout ONCE
|
|
* list list all keys (manifests with token_hash redacted)
|
|
* revoke mark a key as revoked (idempotent; manifest stays for audit)
|
|
*
|
|
* Usage:
|
|
* olp-keys keygen --owner [--name=<label>] [--providers=anthropic,openai,...]
|
|
* olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=...]
|
|
* olp-keys keygen --owner --force (revokes existing owner keys; new owner)
|
|
* olp-keys list [--owner-only] [--include-revoked]
|
|
* olp-keys revoke --id=<key-id>
|
|
*
|
|
* Flags applicable to all subcommands:
|
|
* --olp-home=<path> override ~/.olp (defaults to OLP_HOME env or ~/.olp)
|
|
* --help print usage and exit 0
|
|
*
|
|
* Exit codes:
|
|
* 0 = success
|
|
* 1 = bad usage (missing args, unknown subcommand)
|
|
* 2 = operational failure (key not found, manifest invalid, FS error)
|
|
*
|
|
* The plaintext token from `keygen` is printed exactly once to stdout. It is
|
|
* never written to manifest, audit, or any log line. Operators must capture
|
|
* it immediately; lost → revoke + regenerate. Per ADR 0007 § 5 + § 9.1.
|
|
*/
|
|
|
|
import {
|
|
createKey,
|
|
listKeys,
|
|
revokeKey,
|
|
readManifest,
|
|
} from '../lib/keys.mjs';
|
|
|
|
// ── Arg parsing ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Minimal flag parser. Supports:
|
|
* --flag=value → { flag: 'value' }
|
|
* --flag value → { flag: 'value' } (if next arg doesn't start with --)
|
|
* --flag → { flag: true }
|
|
* Returns { positional: string[], flags: Record<string, string|true> }.
|
|
*/
|
|
export function parseArgv(argv) {
|
|
const positional = [];
|
|
const flags = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg.startsWith('--')) {
|
|
const eq = arg.indexOf('=');
|
|
if (eq > 0) {
|
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
} else {
|
|
const name = arg.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith('--')) {
|
|
flags[name] = next;
|
|
i++;
|
|
} else {
|
|
flags[name] = true;
|
|
}
|
|
}
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
return { positional, flags };
|
|
}
|
|
|
|
const USAGE = `OLP key management CLI
|
|
|
|
Usage:
|
|
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
|
|
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
|
|
olp-keys keygen --anonymous --advertise [--name=<label>] [--providers=<csv>]
|
|
olp-keys list [--owner-only] [--include-revoked]
|
|
olp-keys revoke --id=<key-id>
|
|
|
|
Common flags:
|
|
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
|
|
--help Print this message
|
|
|
|
Authority: ADR 0007 § 9 (bootstrap & recovery); ADR 0011 (anonymous-key
|
|
deployment-context limits — trusted-LAN-only invariant for --advertise).`;
|
|
|
|
// ── Subcommand implementations ────────────────────────────────────────────
|
|
|
|
async function cmdKeygen(flags, ioOut, ioErr) {
|
|
const olpHome = flags['olp-home'];
|
|
const owner = flags.owner === true;
|
|
const force = flags.force === true;
|
|
// D69 (ADR 0011): --anonymous is shorthand for "create a guest-tier key
|
|
// intended to be the zero-config /health.anonymousKey advertise key".
|
|
// It implies --tier=guest and defaults the name to 'anonymous'. The
|
|
// distinct field that actually triggers /health advertisement is
|
|
// --advertise (writes plaintext_advertise into the manifest). Either
|
|
// flag works on its own (--anonymous without --advertise is just a
|
|
// conventionally-named guest key); --advertise without --anonymous is
|
|
// accepted (operator may want to advertise a named guest key).
|
|
const isAnonymous = flags.anonymous === true;
|
|
const advertise = flags.advertise === true;
|
|
let tier = flags.tier;
|
|
if (owner) tier = 'owner';
|
|
if (isAnonymous && !owner) tier = 'guest';
|
|
if (!tier) tier = 'guest';
|
|
if (tier !== 'owner' && tier !== 'guest') {
|
|
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
|
|
return 1;
|
|
}
|
|
// D69: reject --owner --advertise (would expose owner identity unauthenticated).
|
|
if (advertise && tier !== 'guest') {
|
|
ioErr(`Error: --advertise requires guest tier (cannot advertise owner-tier key plaintext). See ADR 0011.\n`);
|
|
return 1;
|
|
}
|
|
let name = flags.name;
|
|
if (!name) {
|
|
if (owner) name = 'owner';
|
|
else if (isAnonymous) name = 'anonymous';
|
|
}
|
|
if (!name) {
|
|
ioErr('Error: --name is required (or use --owner to default to "owner", or --anonymous to default to "anonymous").\n');
|
|
return 1;
|
|
}
|
|
const providersFlag = flags.providers;
|
|
let providers_enabled;
|
|
if (providersFlag === undefined || providersFlag === true) {
|
|
providers_enabled = '*';
|
|
} else if (typeof providersFlag === 'string') {
|
|
providers_enabled = providersFlag.split(',').map(s => s.trim()).filter(Boolean);
|
|
if (providers_enabled.length === 0) providers_enabled = '*';
|
|
} else {
|
|
providers_enabled = '*';
|
|
}
|
|
|
|
// --force: revoke any existing owner keys before creating the new one.
|
|
// revokeKey is async (acquires per-key write lock); await each so the new
|
|
// owner key's createKey doesn't race the revoke writes.
|
|
if (force && tier === 'owner') {
|
|
const existing = listKeys({ olpHome });
|
|
for (const m of existing) {
|
|
if (m.owner_tier === 'owner' && m.revoked_at === null) {
|
|
try {
|
|
await revokeKey({ id: m.id, olpHome });
|
|
ioErr(`Revoked existing owner key id=${m.id} name="${m.name}" (--force).\n`);
|
|
} catch (err) {
|
|
ioErr(`Warning: failed to revoke existing owner key id=${m.id}: ${err?.message ?? err}\n`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome, plaintext_advertise: advertise });
|
|
} catch (err) {
|
|
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
|
|
return 2;
|
|
}
|
|
|
|
// Plaintext token — printed ONCE per ADR § 5 + § 9.1.
|
|
ioOut(`\n OLP key created — capture the plaintext token NOW; it will not be shown again.\n\n`);
|
|
ioOut(` id: ${result.id}\n`);
|
|
ioOut(` name: ${result.manifest.name}\n`);
|
|
ioOut(` owner_tier: ${result.manifest.owner_tier}\n`);
|
|
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
|
|
ioOut(` created_at: ${result.manifest.created_at}\n`);
|
|
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
|
|
if (advertise) {
|
|
// D69 (ADR 0011): explicit warning when plaintext lands on disk + opt-in surface.
|
|
ioOut(` advertise: YES — plaintext stored in manifest; surfaced via /health.anonymousKey\n`);
|
|
ioErr(`\n WARNING: this key's plaintext is now stored on disk + will be exposed via\n`);
|
|
ioErr(` /health.anonymousKey when auth.advertise_anonymous_key=true AND\n`);
|
|
ioErr(` auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.\n`);
|
|
}
|
|
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
|
|
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
|
|
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
|
|
return 0;
|
|
}
|
|
|
|
function cmdList(flags, ioOut, ioErr) {
|
|
const olpHome = flags['olp-home'];
|
|
const ownerOnly = flags['owner-only'] === true;
|
|
const includeRevoked = flags['include-revoked'] === true;
|
|
let keys = listKeys({ olpHome });
|
|
if (ownerOnly) keys = keys.filter(k => k.owner_tier === 'owner');
|
|
if (!includeRevoked) keys = keys.filter(k => k.revoked_at === null);
|
|
|
|
if (keys.length === 0) {
|
|
ioOut('No keys.\n');
|
|
return 0;
|
|
}
|
|
|
|
ioOut(`\n ${keys.length} key${keys.length === 1 ? '' : 's'}:\n\n`);
|
|
for (const k of keys) {
|
|
const providers = typeof k.providers_enabled === 'string'
|
|
? k.providers_enabled
|
|
: `[${k.providers_enabled.join(', ')}]`;
|
|
const status = k.revoked_at === null ? 'active' : `revoked (${k.revoked_at})`;
|
|
const lastUsed = k.last_used_at ?? 'never';
|
|
ioOut(` id=${k.id}\n`);
|
|
ioOut(` name: ${k.name}\n`);
|
|
ioOut(` owner_tier: ${k.owner_tier}\n`);
|
|
ioOut(` providers: ${providers}\n`);
|
|
ioOut(` status: ${status}\n`);
|
|
ioOut(` created: ${k.created_at}\n`);
|
|
ioOut(` last_used: ${lastUsed}\n`);
|
|
if (k.notes) ioOut(` notes: ${k.notes}\n`);
|
|
ioOut('\n');
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
async function cmdRevoke(flags, ioOut, ioErr) {
|
|
const olpHome = flags['olp-home'];
|
|
const id = typeof flags.id === 'string' ? flags.id : null;
|
|
if (!id) {
|
|
ioErr('Error: --id=<key-id> is required.\n');
|
|
return 1;
|
|
}
|
|
|
|
// Confirm the key exists before attempting revoke (clearer error path).
|
|
const m = readManifest(id, { olpHome });
|
|
if (m === null) {
|
|
ioErr(`Error: no key with id="${id}".\n`);
|
|
return 2;
|
|
}
|
|
if (m.revoked_at !== null) {
|
|
ioOut(`Key id=${id} already revoked at ${m.revoked_at} (no-op).\n`);
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
await revokeKey({ id, olpHome });
|
|
} catch (err) {
|
|
ioErr(`Error: revokeKey failed: ${err?.message ?? err}\n`);
|
|
return 2;
|
|
}
|
|
|
|
ioOut(`Revoked key id=${id} name="${m.name}".\n`);
|
|
return 0;
|
|
}
|
|
|
|
// ── CLI entry ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run the CLI with explicit argv + IO streams. Returns the intended exit code.
|
|
* Exported for tests (no process.exit, no direct stdout/stderr).
|
|
*
|
|
* @param {string[]} argv - args AFTER the subcommand name (e.g., ['keygen', '--owner']).
|
|
* The first element is the subcommand.
|
|
* @param {object} [opts]
|
|
* @param {(s: string) => void} [opts.out] - stdout writer; defaults to process.stdout.write
|
|
* @param {(s: string) => void} [opts.err] - stderr writer; defaults to process.stderr.write
|
|
* @returns {Promise<number>} exit code 0 / 1 / 2
|
|
*/
|
|
export async function runCli(argv, opts = {}) {
|
|
const ioOut = opts.out ?? (s => process.stdout.write(s));
|
|
const ioErr = opts.err ?? (s => process.stderr.write(s));
|
|
|
|
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
|
|
ioOut(USAGE + '\n');
|
|
return argv.length === 0 ? 1 : 0;
|
|
}
|
|
|
|
const [subcommand, ...rest] = argv;
|
|
const { flags } = parseArgv(rest);
|
|
|
|
switch (subcommand) {
|
|
case 'keygen': return await cmdKeygen(flags, ioOut, ioErr);
|
|
case 'list': return cmdList(flags, ioOut, ioErr);
|
|
case 'revoke': return await cmdRevoke(flags, ioOut, ioErr);
|
|
default:
|
|
ioErr(`Error: unknown subcommand "${subcommand}".\n${USAGE}\n`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Main guard: only run when invoked as the entrypoint. ESM equivalent of
|
|
// `require.main === module` is comparing import.meta.url against argv[1].
|
|
const isMain = (() => {
|
|
try {
|
|
return import.meta.url === `file://${process.argv[1]}`;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
if (isMain) {
|
|
runCli(process.argv.slice(2)).then(code => process.exit(code));
|
|
}
|