Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
cb5d30646a feat(log): add registerLogCommand for mosaic log CLI surface
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Adds mosaic log tail|search|export|level subcommands to @mosaicstack/log,
wires registerLogCommand into the root mosaic CLI, and ships a smoke test
that asserts command structure without opening the database. Ref CU-05-07.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:22:28 -05:00
8 changed files with 259 additions and 193 deletions

View File

@@ -1,193 +0,0 @@
# Gateway Admin Token Recovery — Implementation Plan
**Mission:** `cli-unification-20260404`
**Task:** `CU-03-01` (planning only — no runtime code changes)
**Status:** Design locked (Session 1) — BetterAuth cookie-based recovery
---
## 1. Problem Statement
The gateway installer strands operators when the admin user exists but the admin
API token is missing. Concrete trigger:
- `~/.config/mosaic/gateway/meta.json` was deleted / regenerated.
- The installer was re-run after a previous successful bootstrap.
Flow today (`packages/mosaic/src/commands/gateway/install.ts:375-400`):
1. `bootstrapFirstUser` hits `GET /api/bootstrap/status`.
2. Server returns `needsSetup: false` because `users` count > 0.
3. Installer logs `Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)` and returns.
4. The operator now has:
- No token in `meta.json`.
- No CLI path to mint a new one (`mosaic gateway <anything>` that needs the token fails).
- `POST /api/bootstrap/setup` locked out — it only runs when `users` count is zero (`apps/gateway/src/admin/bootstrap.controller.ts:34-37`).
- `POST /api/admin/tokens` gated by `AdminGuard` — requires either a bearer token (which they don't have) or a BetterAuth session (which they don't have in the CLI).
Dead end. The web UI is the only escape hatch today, and for headless installs even that may be inaccessible.
## 2. Design Summary
The BetterAuth session cookie is the authority. The operator runs
`mosaic gateway login` to sign in with email/password, which persists a session
cookie via `saveSession` (reusing `packages/mosaic/src/auth.ts`). With a valid
session, `mosaic gateway config recover-token` (stranded-operator entry point)
and `mosaic gateway config rotate-token` call the existing authenticated admin
endpoint `POST /api/admin/tokens` using the cookie, then persist the returned
plaintext to `meta.json` via `writeMeta`. **No new server endpoints are
required** — `AdminGuard` already accepts BetterAuth session cookies via its
`validateSession` path (`apps/gateway/src/admin/admin.guard.ts:90-120`).
## 3. Surface Contract
### 3.1 Server — no changes required
| Endpoint | Status | Notes |
| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `POST /api/admin/tokens` | **Reuse as-is** | `admin-tokens.controller.ts:46-72`. Returns `{ id, label, scope, expiresAt, lastUsedAt, createdAt, plaintext }`. |
| `GET /api/admin/tokens` | **Reuse** | Useful for `mosaic gateway config tokens list` follow-on (out of scope for CU-03-01, but trivial once auth path exists). |
| `DELETE /api/admin/tokens/:id` | **Reuse** | Used by rotate flow for optional old-token revocation. |
| `POST /api/bootstrap/setup` | **Unchanged** | Remains first-user-only; not part of recovery. |
`AdminGuard.validateSession` takes BetterAuth cookies from `request.raw.headers`
via `fromNodeHeaders` and calls `auth.api.getSession({ headers })`. It also
enforces `role === 'admin'`. This is exactly the path the CLI will hit with
`Cookie: better-auth.session_token=...`.
**Confirmed feasible** during CU-03-01 investigation.
### 3.2 `mosaic gateway login`
Thin wrapper over the existing top-level `mosaic login`
(`packages/mosaic/src/cli.ts:42-76`) with gateway-specific defaults pulled from
`readMeta()`.
| Aspect | Behavior |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Default gateway URL | `http://${meta.host}:${meta.port}` from `readMeta()`, fallback `http://localhost:14242`. |
| Flow | Prompt email + password -> `signIn()` -> `saveSession()`. |
| Persistence | `~/.mosaic/session.json` via existing `saveSession` (7-day expiry). |
| Decision | **Thin wrapper**, not alias. Rationale: defaults differ (reads `meta.json`), and discoverability under `mosaic gateway --help`. |
| Implementation | Share the sign-in logic by extracting a small `runLogin(gatewayUrl, email?, password?)` helper; both commands call it. |
### 3.3 `mosaic gateway config rotate-token`
| Aspect | Behavior |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Precondition | Valid session (via `loadSession` + `validateSession`). On failure, print: "Not signed in — run `mosaic gateway login`" and exit non-zero. |
| Request | `POST ${gatewayUrl}/api/admin/tokens` with header `Cookie: <session>`, body `{ label: "CLI token (rotated YYYY-MM-DD)" }`. |
| On success | Read meta via `readMeta()`, set `meta.adminToken = plaintext`, `writeMeta(meta)`. Print the token banner (reuse `printAdminTokenBanner` shape). |
| Old token | **Optional `--revoke-old`** flag. When set and a previous `meta.adminToken` existed, call `DELETE /api/admin/tokens/:id` after rotation. Requires listing first to find the id; punt to CU-03-02 decision. Document as nice-to-have. |
| Exit codes | `0` success; `1` network error; `2` auth error; `3` server rejection. |
### 3.4 `mosaic gateway config recover-token`
Superset of `rotate-token` with an inline login nudge — the "stranded operator"
entry point.
| Step | Action |
| ---- | -------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `readMeta()` — derive gateway URL. If meta is missing entirely, fall back to `--gateway` flag or default. |
| 2 | `loadSession(gatewayUrl)` then `validateSession`. If either fails, prompt inline: email + password -> `signIn` -> `saveSession`. |
| 3 | `POST /api/admin/tokens` with cookie, label `"Recovered via CLI YYYY-MM-DDTHH:mm"`. |
| 4 | Persist plaintext to `meta.json` via `writeMeta`. |
| 5 | Print the token banner and next-steps hints (e.g. `mosaic gateway status`). |
| 6 | Exit `0`. |
Key property: this command is **runnable with nothing but email+password in hand**.
It assumes the gateway is up but assumes no prior CLI session state.
### 3.5 File touch list (for CU-03-02..05 execution)
| File | Change |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `packages/mosaic/src/commands/gateway.ts` | Register `login`, `config recover-token`, `config rotate-token` subcommands under `gw`. |
| `packages/mosaic/src/commands/gateway/config.ts` | Add `runRecoverToken`, `runRotateToken` handlers; export from module. |
| `packages/mosaic/src/commands/gateway/login.ts` (new) | Thin wrapper calling shared `runLogin` helper with meta-derived default URL. |
| `packages/mosaic/src/auth.ts` | No change expected. Possibly export a `requireSession(gatewayUrl)` helper (reuse pattern). |
| `packages/mosaic/src/commands/gateway/install.ts` | `bootstrapFirstUser` branch: "user exists, no token" -> offer recovery (see Section 4). |
## 4. Installer Fix (CU-03-06 preview)
Current stranding point is `install.ts:388-395`. The fix:
```
if (!status.needsSetup) {
if (meta.adminToken) {
// unchanged — happy path
} else {
// NEW: prompt "Admin exists but no token on file. Recover now? [Y/n]"
// If yes -> call runRecoverToken(gatewayUrl) inline (interactive):
// - prompt email + password
// - signIn -> saveSession
// - POST /api/admin/tokens
// - writeMeta(meta) with returned plaintext
// - print banner
// If no -> print the current stranded message but include:
// "Run `mosaic gateway config recover-token` when ready."
}
}
```
Shape notes (actual code lands in CU-03-06):
- Extract the recovery body so it can be called **both** from the standalone
command and from `bootstrapFirstUser` without duplicating prompts.
- Reuse the same `rl` readline interface already open in `bootstrapFirstUser`
for the inline prompts.
- Preserve non-interactive behavior: if `process.stdin.isTTY` is false, skip the
prompt and emit the "run recover-token" hint only.
## 5. Test Strategy (CU-03-07 scope)
### 5.1 Happy paths
| Command | Scenario | Expected |
| ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- |
| `mosaic gateway login` | Valid creds | `session.json` written, 7-day expiry, exit 0 |
| `mosaic gateway config rotate-token` | Valid session, server reachable | `meta.json` updated, banner printed, new token usable |
| `mosaic gateway config recover-token` | No session, valid creds, server reachable | Prompts for creds, writes session + meta, exit 0 |
| Installer inline recovery | Re-run after `meta.json` wipe, operator says yes | Meta restored, banner printed, no manual CLI step needed |
### 5.2 Error paths (must all produce actionable messages and non-zero exit)
| Failure | Expected handling |
| --------------------------------- | --------------------------------------------------------------------------------- |
| Invalid email/password | BetterAuth 401 surfaced as "Sign-in failed: <server message>", exit 2 |
| Expired stored session | Recover command silently re-prompts; rotate command exits 2 with "run login" hint |
| Gateway down / connection refused | "Could not reach gateway at <url>" exit 1 |
| Server rejects token creation | Print status + body excerpt, exit 3 |
| Meta file missing (recover) | Fall back to `--gateway` flag or default; warn that meta will be created |
| Non-admin user | `AdminGuard` 403 surfaced as "User is not an admin", exit 2 |
### 5.3 Integration test (recommended)
Spin up gateway in test harness, create admin user via `/api/bootstrap/setup`,
wipe `meta.json`, invoke `mosaic gateway config recover-token` programmatically,
assert new `meta.adminToken` works against `GET /api/admin/tokens`.
## 6. Risks & Open Questions
| # | Item | Severity | Mitigation |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| 1 | `AdminGuard.validateSession` calls `getSession` with `fromNodeHeaders(request.raw.headers)`. CLI sends `Cookie:` header only. Confirm BetterAuth reads from `Cookie`, not `Set-Cookie`. | Low | Confirmed — `mosaic login` + `mosaic tui` already use this flow successfully (`cli.ts:137-181`). |
| 2 | Session cookie local expiry (7d) vs BetterAuth server-side expiry may drift. | Low | `validateSession` hits `get-session`; handle 401 by re-prompting. |
| 3 | Label collision / unbounded token growth if operators run `recover-token` repeatedly. | Low | Include ISO timestamp in label. Optional `--revoke-old` in CU-03-02. Add `tokens list/prune` later. |
| 4 | `mosaic login` exists at top level and `mosaic gateway login` is a wrapper — risk of confusion. | Low | Document that `gateway login` is the preferred entry for gateway operators; top-level stays for compatibility. |
| 5 | `meta.json` write is not atomic. Crash between token creation and `writeMeta` leaves an orphan token server-side with no plaintext on disk. | Medium | Accept for now — re-running `recover-token` mints a fresh token. Document as known limitation. |
| 6 | Non-TTY installer runs (CI, headless provisioners) cannot prompt for creds interactively. | Medium | Installer inline recovery must skip prompt when `!process.stdin.isTTY`; emit the recover-token hint. |
| 7 | If `BETTER_AUTH_SECRET` rotates between login and recover, the session cookie is invalid — user must re-login. Acceptable but surface a clear error. | Low | Error handler maps 401 on recover -> "Session invalid; re-run `mosaic gateway login`". |
| 8 | No MFA today. When MFA lands, BetterAuth sign-in will return a challenge, not a cookie — recovery UX will need a second prompt step. | Future | Out of scope for this mission. Flag for future CLI work. |
## 7. Downstream Task Hooks
| Task | Scope |
| -------- | -------------------------------------------------------------------------- |
| CU-03-02 | Implement `mosaic gateway login` wrapper + shared `runLogin` extraction. |
| CU-03-03 | Implement `mosaic gateway config rotate-token`. |
| CU-03-04 | Implement `mosaic gateway config recover-token`. |
| CU-03-05 | Wire commands into `gateway.ts` registration, update `--help` copy. |
| CU-03-06 | Installer inline recovery hook in `bootstrapFirstUser`. |
| CU-03-07 | Tests per Section 5. |
| CU-03-08 | Docs: update gateway install README + operator runbook with recovery flow. |

View File

@@ -23,6 +23,7 @@
},
"dependencies": {
"@mosaicstack/db": "workspace:*",
"commander": "^13.0.0",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {

View File

@@ -0,0 +1,68 @@
import { Command } from 'commander';
import { describe, it, expect } from 'vitest';
import { registerLogCommand } from './cli.js';
function buildTestProgram(): Command {
const program = new Command('mosaic');
program.exitOverride(); // prevent process.exit in tests
registerLogCommand(program);
return program;
}
describe('registerLogCommand', () => {
it('registers a "log" subcommand on the parent', () => {
const program = buildTestProgram();
const names = program.commands.map((c) => c.name());
expect(names).toContain('log');
});
it('log command has tail, search, export, and level subcommands', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log');
expect(logCmd).toBeDefined();
const subNames = logCmd!.commands.map((c) => c.name());
expect(subNames).toContain('tail');
expect(subNames).toContain('search');
expect(subNames).toContain('export');
expect(subNames).toContain('level');
});
it('tail subcommand has expected options', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!;
const optionNames = tailCmd.options.map((o) => o.long);
expect(optionNames).toContain('--agent');
expect(optionNames).toContain('--level');
expect(optionNames).toContain('--category');
expect(optionNames).toContain('--tier');
expect(optionNames).toContain('--limit');
expect(optionNames).toContain('--db');
});
it('search subcommand accepts a positional query argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!;
// Commander stores positional args in _args
const argNames = searchCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('query');
});
it('export subcommand accepts a positional path argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!;
const argNames = exportCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('path');
});
it('level subcommand accepts a positional level argument', () => {
const program = buildTestProgram();
const logCmd = program.commands.find((c) => c.name() === 'log')!;
const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!;
const argNames = levelCmd.registeredArguments.map((a) => a.name());
expect(argNames).toContain('level');
});
});

177
packages/log/src/cli.ts Normal file
View File

@@ -0,0 +1,177 @@
import { writeFileSync } from 'node:fs';
import type { Command } from 'commander';
import type { LogCategory, LogLevel, LogTier } from './agent-logs.js';
interface FilterOptions {
agent?: string;
level?: string;
category?: string;
tier?: string;
limit?: string;
db?: string;
}
function parseLimit(raw: string | undefined, defaultVal = 50): number {
if (!raw) return defaultVal;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : defaultVal;
}
function buildQuery(opts: FilterOptions) {
return {
...(opts.agent ? { sessionId: opts.agent } : {}),
...(opts.level ? { level: opts.level as LogLevel } : {}),
...(opts.category ? { category: opts.category as LogCategory } : {}),
...(opts.tier ? { tier: opts.tier as LogTier } : {}),
limit: parseLimit(opts.limit),
};
}
async function openDb(connectionString: string) {
const { createDb } = await import('@mosaicstack/db');
return createDb(connectionString);
}
function resolveConnectionString(opts: FilterOptions): string | undefined {
return opts.db ?? process.env['DATABASE_URL'];
}
/**
* Register log subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerLogCommand(parent: Command): void {
const log = parent.command('log').description('Query and manage agent logs');
// ─── tail ───────────────────────────────────────────────────────────────
log
.command('tail')
.description('Tail recent agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
if (logs.length === 0) {
console.log('No logs found.');
return;
}
for (const entry of logs) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── search ─────────────────────────────────────────────────────────────
log
.command('search <query>')
.description('Full-text search over agent logs')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (query: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const baseQuery = buildQuery(opts);
const logs = await svc.logs.query(baseQuery);
const lowerQ = query.toLowerCase();
const matched = logs.filter(
(e) =>
e.content.toLowerCase().includes(lowerQ) ||
(e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)),
);
if (matched.length === 0) {
console.log('No matching logs found.');
return;
}
for (const entry of matched) {
const ts = new Date(entry.createdAt).toISOString();
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
}
} finally {
await handle.close();
}
});
// ─── export ─────────────────────────────────────────────────────────────
log
.command('export <path>')
.description('Export matching logs to an NDJSON file')
.option('--agent <id>', 'Filter by agent/session ID')
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
.option('--limit <n>', 'Number of logs to export (default 50)', '50')
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
.action(async (outputPath: string, opts: FilterOptions) => {
const connStr = resolveConnectionString(opts);
if (!connStr) {
console.error('Database connection required: use --db or set DATABASE_URL');
process.exit(1);
}
const handle = await openDb(connStr);
try {
const { createLogService } = await import('./log-service.js');
const svc = createLogService(handle.db);
const query = buildQuery(opts);
const logs = await svc.logs.query(query);
const ndjson = logs.map((e) => JSON.stringify(e)).join('\n');
writeFileSync(outputPath, ndjson, 'utf8');
console.log(`Exported ${logs.length} log(s) to ${outputPath}`);
} finally {
await handle.close();
}
});
// ─── level ──────────────────────────────────────────────────────────────
log
.command('level <level>')
.description('Set runtime log level for the connected log service')
.action((level: string) => {
void level;
console.log(
'Runtime log level adjustment is not supported in current mode (DB-backed log service).',
);
process.exitCode = 0;
});
}

View File

@@ -9,3 +9,4 @@ export {
type LogTier,
type LogQuery,
} from './agent-logs.js';
export { registerLogCommand } from './cli.js';

View File

@@ -29,6 +29,7 @@
"dependencies": {
"@mosaicstack/config": "workspace:*",
"@mosaicstack/forge": "workspace:*",
"@mosaicstack/log": "workspace:*",
"@mosaicstack/macp": "workspace:*",
"@mosaicstack/prdy": "workspace:*",
"@mosaicstack/quality-rails": "workspace:*",

View File

@@ -2,6 +2,7 @@
import { createRequire } from 'module';
import { Command } from 'commander';
import { registerLogCommand } from '@mosaicstack/log';
import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
@@ -318,6 +319,10 @@ registerMissionCommand(program);
registerQualityRails(program);
// ─── log ─────────────────────────────────────────────────────────────────
registerLogCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
program

6
pnpm-lock.yaml generated
View File

@@ -401,6 +401,9 @@ importers:
'@mosaicstack/db':
specifier: workspace:*
version: link:../db
commander:
specifier: ^13.0.0
version: 13.1.0
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)
@@ -460,6 +463,9 @@ importers:
'@mosaicstack/forge':
specifier: workspace:*
version: link:../forge
'@mosaicstack/log':
specifier: workspace:*
version: link:../log
'@mosaicstack/macp':
specifier: workspace:*
version: link:../macp