diff --git a/docs/aura/FORK.md b/docs/aura/FORK.md index 95a95f93bb5..13905b5da26 100644 --- a/docs/aura/FORK.md +++ b/docs/aura/FORK.md @@ -24,7 +24,7 @@ and after every upstream merge. | `packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts` | one symbol: the wizard scene header renders `AURA_LOGO` | | `packages/coding-agent/src/tools/renderers.ts` | one import + one `...runtimeToolRenderers` spread at the head of `toolRenderers`, registering the nine runtime tool renderers. The spread is first so a future upstream entry with the same key would win rather than be silently shadowed; all renderer logic lives in the fork-owned `tools/runtime-renderer.ts`, so this row stays a two-line change through any merge | | `packages/coding-agent/src/cli/gallery-fixtures/index.ts` | one import + one `...runtimeFixtures` spread, adding the runtime tool family's `omp gallery` sample data (the fixtures themselves are the fork-owned sibling module `gallery-fixtures/runtime.ts`). Without it the coverage test still passes — unfixtured tools fall back to a generic sample — but the runtime rows render as placeholder args | -| `packages/coding-agent/src/config/settings-schema.ts` | `theme.dark` default = `aura` (was `titanium`) and `theme.light` default = `aura-light` (was `light`), so the fork's terminal-background auto light/dark switching stays on-brand in both directions; `runtime.*` settings (`runtime.enabled`, `runtime.adapter` with process default and explicit-embedded no-fallback, `runtime.autoDownload`, `runtime.path`, `runtime.version`, `runtime.embeddedPath`) added to the `tools` tab; Python capability hierarchy: parent `python.enabled` and embedded-runtime child `python.embedded` default on, local snake-action child `python.shell` defaults off (the `$` action rides the embedded runtime; this key only adds a subprocess-interpreter fallback for hosts without it); `DEFAULT_BASH_INTERCEPTOR_RULES` retains only the ordinary user-controlled dedicated-tool nudges | +| `packages/coding-agent/src/config/settings-schema.ts` | `theme.dark` default = `aura` (was `titanium`) and `theme.light` default = `aura-light` (was `light`), so the fork's terminal-background auto light/dark switching stays on-brand in both directions; `runtime.*` settings (`runtime.enabled`, `runtime.adapter` with process default and explicit-embedded no-fallback, `runtime.autoDownload`, `runtime.path`, `runtime.version`, `runtime.embeddedPath`) added to the `tools` tab; Python capability hierarchy: parent `python.enabled` and embedded-runtime child `python.embedded` default on, local snake-action child `python.shell` defaults off (the `$` action rides the embedded runtime; this key only adds a subprocess-interpreter fallback for hosts without it); `DEFAULT_BASH_INTERCEPTOR_RULES` retains only the ordinary user-controlled dedicated-tool nudges; `cloud.stats.enabled` (default off, same reasoning as `cloud.telemetry.enabled`) gates pushing local `stats` records to Aura's hosted observability panel and re-pointing `aura stats` at it | | `packages/coding-agent/src/tools/report-tool-issue.ts`, `src/cli/grievances-cli.ts`, `test/tools/report-tool-issue.test.ts` | no default Auto-QA collector: `dev.autoqaPush.endpoint` ships empty, so consent enables local recording only and grievances stay queued until an operator configures an endpoint (setting, `AURA_QA_URL`/`PI_AUTO_QA_PUSH_URL`, or `AURA_DOMAIN`). Tests pin the empty default and preserve explicit setting / `PI_AUTO_QA_PUSH_URL` precedence. Keep upstream batching, consent, local retention, and push behavior unchanged when resolving merges | | `packages/coding-agent/src/tools/bash.ts` | selects configured rules through `activeBashInterceptorRules(getBashInterceptorRules(), settings.get("bashInterceptor.enabled"))`; the toggle gates every rule, and direct runtime-binary commands are not intercepted | | `packages/coding-agent/src/tools/index.ts` | `ToolSession.getRuntimeService?: () => RuntimeService \| undefined` accessor added beside `getMnemopiSessionState`, plus the root-owned `runtimeServiceScope` propagated into every subagent executor; registers engine-aware `run` and validation-only `check` as essential, `insights`/`profile` plus four specialized JVM tools (`jvm_disassemble`, `jvm_format`, `jvm_jar`, `jvm_deps`) and hub-supervised `serve` as discoverable, all on the `runtime.enabled` gate | @@ -47,7 +47,7 @@ and after every upstream merge. | `packages/coding-agent/src/cli/flag-tables.ts` | `--prepend-system-prompt ` and `--runtime ` added to `STRING_SETTERS` (the single source of truth for string-valued launch flags, so the profile bootstrap and subcommand resolver pick it up automatically) | | `packages/coding-agent/src/main.ts` | `discoverPrependSystemPromptFile` (`PREPEND_SYSTEM.md`, project then global, mirroring `discoverAppendSystemPromptFile`), resolved in `buildSessionOptions` and applied through a fourth `applyResolvedSystemPromptInputs` parameter; `--prepend-system-prompt` also joins the fork-cache-shape check; the `--version` launch-flag path prints `RUNTIME_PROTOCOL_LINE` under `VERSION`, matching the top-level interception in `cli.ts`; `--runtime ` applied as an ephemeral `Settings.override("runtime.path", …)` alongside the other CLI-flag overrides, so every `settings.get("runtime.path")` site (innate-tool endpoint resolution included) observes it | | `packages/coding-agent/src/commands/launch.ts` | `prepend-system-prompt: Flags.string(...)` and `runtime: Flags.string(...)` declared for oclif's generated `--help`; the real parse lives in `cli/args.ts` (same pattern as `--auto-approve` / `--approval-mode`) | -| `packages/coding-agent/src/cli-commands.ts` | register runtime and doctor commands | +| `packages/coding-agent/src/cli-commands.ts` | register runtime and doctor commands; registers `account` (Aura account login/logout/status — see `cli/account-cli.ts` below) | | `packages/coding-agent/src/sdk.ts` | `CreateAgentSessionOptions.prependSystemPrompt`, forwarded to `buildSystemPrompt` as `resolvedPrependSystemPrompt` and included in the fork-cache-shape check; wires selected/composite runtime settings onto the lazy `toolSession.getRuntimeService` accessor; creates a root-owned runtime cache/config scope, exposes the canonical settings snapshot reader for private top-level session factories, and propagates that scope into every descendant; every top-level session sharing that scope acquires an idempotent lease while subagents never acquire or release one; startup failure releases the lease; last release asynchronously evicts/closes only that scope | | `packages/coding-agent/src/prompts/system/system-prompt.md`, `src/discovery/claude-plugins.ts` | promotes runtime selection and the universal engineering method into the inherent system layer; routes standalone Java/Kotlin through runtime/JVM tools and project builds through declared project commands; canonical Superpowers workflow skills are filtered only within the canonical plugin provider, while domain skills and same-named user/project skills remain discoverable | | `packages/coding-agent/src/telemetry/{events,metrics,sink-otlp}.ts` | adds bounded `runtime.call.completed` events, `aura.runtime.calls` and `aura.runtime.duration` instruments, and structured OTLP logs without source, arguments, output, paths, or exception messages | @@ -95,6 +95,10 @@ and after every upstream merge. | `AGENTS.md` | appended the `## Aura fork conventions` section (points contributors at this file, states the runtime naming rule, locates specs/plans) | | `biome.json` | excludes checked-in `capnp-es` runtime protocol bindings from source formatting/lint; generated output is verified byte-for-byte by `scripts/sync-embedded-runtime-protocol.ts --check` instead | | `bun.lock` | `aura` bin entry plus the exact `capnp-es@0.0.14` runtime dependency and coding-agent-local `typescript@5.9.3` generator peer. Regenerate with `bun install` rather than resolving a merge conflict by hand | +| `packages/coding-agent/src/cloud/token-manager.ts` | fixed `verifyAuraToken`'s JWT claim names to match elide-cloud's real issuer, discovered when this module — built and unit-tested but never exercised against a real server — got its first real consumer: reads `org`/`act`/`realm` (were `org_id`/`account_id`/`realm_id`) and `scope` as one RFC-6749 space-delimited string (was a `scopes` JSON array), matching `workers/auth/tokens.ts` / `packages/worker-auth/src/principal.ts` in elide-dev/cloud exactly. `test/cloud/aura-auth-fixture.ts`'s `userClaims`/`apiKeyClaims` and every override in `token-manager.test.ts`/`auth.test.ts` updated to match | +| `packages/coding-agent/src/cloud/deployment.ts` | adds the `stats` `AuraServiceSurface` (`statsIngestBaseUrl`, derived as `https://stats-ingest.${AURA_DOMAIN}`, matching elide-dev/cloud's `workers/stats`) and `resolveStatsPanelUrl` (derives `https://stats.${AURA_DOMAIN}`, matching `workers/stats-panel` — deliberately not a full surface, since a browser-navigation target carries no bearer/override tiers); `stats` added to `CloudConsumer`/`CLOUD_CONSUMER_SETTINGS`/`CLOUD_SWITCH_DEFAULTS` (off by default) and `LONGEST_DERIVED_PREFIX` updated since `stats-ingest.` is now the longest prefix | +| `packages/coding-agent/src/cli/stats-cli.ts` | after the existing local sync, `tryOpenHostedPanel()` checks `cloud.stats.enabled`; when on and signed in (`aura account login`) it pushes the most recent local records to the Aura ingest endpoint and opens the hosted panel (`stats.${AURA_DOMAIN}`) instead of starting the local server. Falls back to the unchanged local flow for every reason this might not apply — switch off (the default), Aura not configured, not signed in, or the push failing — none of those are hard errors. `omp`'s `stats` is untouched | +| `packages/stats/src/index.ts` | exports the pre-existing `getRecentRequests` (was internal to `aggregator.ts`) so `stats-cli.ts` can read the batch to push without a new aggregator function | ### Upstream tests de-hardcoded for the rebrand @@ -146,6 +150,13 @@ changes the template sources. ## Fork-added files and directories (additive, no merge risk) +- `packages/coding-agent/src/commands/account.ts`, `src/cli/account-cli.ts` — + `aura account login|logout|status`: runs the (pre-existing but previously + unwired — see the `cloud/token-manager.ts` row above) `AuraAuthClient` + device flow to completion, so an Elide account can be signed in for + cloud-backed surfaces. Registered in `cli-commands.ts`. `omp` has no + equivalent command + - `packages/utils/src/distribution.ts` — single source of truth for aura's distribution coordinates (release repo `elide-dev/aura`, update channel `github`, install URL, brew/mise/npm coordinates), re-exported from the diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index 3325c147de2..247c7620382 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -15,6 +15,7 @@ import { flagConsumesValue } from "./cli/flag-tables"; export const commands: CommandEntry[] = [ { name: "launch", load: () => import("./commands/launch").then(m => m.default) }, { name: "acp", load: () => import("./commands/acp").then(m => m.default) }, + { name: "account", load: () => import("./commands/account").then(m => m.default) }, { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) }, { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) }, { name: "agents", load: () => import("./commands/agents").then(m => m.default) }, diff --git a/packages/coding-agent/src/cli/account-cli.ts b/packages/coding-agent/src/cli/account-cli.ts new file mode 100644 index 00000000000..f25d9120514 --- /dev/null +++ b/packages/coding-agent/src/cli/account-cli.ts @@ -0,0 +1,149 @@ +/** + * CLI handler for `aura account` — sign in to (or out of) the Aura account + * used by cloud-backed surfaces (currently: the hosted observability panel). + * + * This is aura-only: `omp` carries none of this. See docs/aura/FORK.md. + */ +import * as readline from "node:readline"; +import chalk from "chalk"; +import { AuraAuthClient, type AuraLoginPresenter, type AuraOrganizationOption } from "../cloud/auth"; +import { + auraDeploymentFor, + readCloudSwitches, + resolveAuraDeployment, + resolveServiceEndpoint, +} from "../cloud/deployment"; +import { isAuraCloudError } from "../cloud/errors"; +import { AuraTokenStore } from "../cloud/token-store"; +import { Settings } from "../config/settings"; +import { openPath } from "../utils/open"; + +export type AccountAction = "login" | "logout" | "status"; +export const ACCOUNT_ACTIONS = ["login", "logout", "status"] as const; + +export interface AccountCommandArgs { + action: AccountAction; + flags: { + json?: boolean; + label?: string; + open?: boolean; + force?: boolean; + }; +} + +/** Resolve the configured auth origin, or `undefined` when Aura is not configured at all. */ +function resolveAuthOrigin(settings: Settings): string | undefined { + const deployment = resolveAuraDeployment({ env: process.env }); + const switches = readCloudSwitches(settings); + const narrowed = auraDeploymentFor("account", deployment, switches); + return resolveServiceEndpoint("auth", { deployment: narrowed })?.url; +} + +async function openStore(): Promise { + return await AuraTokenStore.open(); +} + +function cliPresenter(openBrowser: boolean): AuraLoginPresenter { + return { + present(approval) { + console.log(chalk.bold("\nSign in to Aura")); + console.log(` Go to: ${chalk.cyan(approval.verificationUri)}`); + console.log(` Enter code: ${chalk.bold(approval.userCode)}`); + if (openBrowser) console.log(chalk.dim(" (opening your browser to the pre-filled link...)")); + console.log(chalk.dim("Waiting for approval...\n")); + }, + open(url) { + openPath(url); + }, + async selectOrganization(options: readonly AuraOrganizationOption[]): Promise { + console.log(chalk.bold("\nMultiple organizations are available:")); + options.forEach((option, index) => { + console.log(` ${index + 1}. ${option.name ?? option.id}`); + }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await new Promise(resolve => rl.question("Select a number: ", resolve)); + const index = Number.parseInt(answer.trim(), 10) - 1; + return options[index]?.id; + } finally { + rl.close(); + } + }, + }; +} + +function explainAuthError(error: unknown): string { + if (isAuraCloudError(error)) { + switch (error.code) { + case "login_required": + return "Not signed in. Run `aura account login` first."; + case "relogin_required": + return "Your Aura session has expired. Run `aura account login` again."; + case "access_denied": + return "Sign-in was denied."; + case "expired": + return "The sign-in code expired before it was approved."; + case "invalid_configuration": + return "AURA_DOMAIN (or an explicit AURA_AUTH_URL) is not configured."; + default: + return `Aura request failed: ${error.code}`; + } + } + return error instanceof Error ? error.message : String(error); +} + +export async function runAccountCommand(cmd: AccountCommandArgs): Promise { + const settings = await Settings.init(); + const authOrigin = resolveAuthOrigin(settings); + if (!authOrigin) { + const message = "Aura is not configured — set AURA_DOMAIN (or AURA_AUTH_URL) first."; + if (cmd.flags.json) console.log(JSON.stringify({ error: message })); + else console.error(chalk.red(message)); + process.exitCode = 1; + return; + } + + const store = await openStore(); + const client = new AuraAuthClient({ authOrigin, store }); + + try { + switch (cmd.action) { + case "login": { + const result = await client.login({ + label: cmd.flags.label, + open: cmd.flags.open ?? true, + presenter: cliPresenter(cmd.flags.open ?? true), + }); + if (cmd.flags.json) { + console.log(JSON.stringify({ signedIn: true, ...result })); + } else { + console.log(chalk.green(`Signed in (account ${result.identity.accountId}).`)); + } + return; + } + case "logout": { + const result = await client.logout({ force: cmd.flags.force }); + if (cmd.flags.json) console.log(JSON.stringify(result)); + else + console.log(chalk.green(result.revoked ? "Signed out." : "Signed out locally (server logout failed).")); + return; + } + case "status": { + const status = client.status(); + if (cmd.flags.json) { + console.log(JSON.stringify(status)); + } else if (!status.signedIn) { + console.log(chalk.dim("Not signed in to Aura.")); + } else { + console.log(chalk.green(`Signed in as account ${status.identity?.accountId} (${status.issuer}).`)); + } + return; + } + } + } catch (error) { + const message = explainAuthError(error); + if (cmd.flags.json) console.log(JSON.stringify({ error: message })); + else console.error(chalk.red(message)); + process.exitCode = 1; + } +} diff --git a/packages/coding-agent/src/cli/stats-cli.ts b/packages/coding-agent/src/cli/stats-cli.ts index 31177ab71d5..8855f9e48fc 100644 --- a/packages/coding-agent/src/cli/stats-cli.ts +++ b/packages/coding-agent/src/cli/stats-cli.ts @@ -4,9 +4,21 @@ * Handles `omp stats` subcommand for viewing AI usage statistics. */ +import type { MessageStats } from "@oh-my-pi/omp-stats"; import { truncateToWidth } from "@oh-my-pi/pi-tui/utils"; import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; +import { + auraDeploymentFor, + readCloudSwitches, + resolveAuraDeployment, + resolveServiceEndpoint, + resolveStatsPanelUrl, +} from "../cloud/deployment"; +import { isAuraCloudError } from "../cloud/errors"; +import { TokenManager } from "../cloud/token-manager"; +import { AuraTokenStore } from "../cloud/token-store"; +import { Settings } from "../config/settings"; import { openPath } from "../utils/open"; /** @@ -106,6 +118,113 @@ function normalizePremiumRequests(n: number): number { return Math.round((n + Number.EPSILON) * 100) / 100; } +// ============================================================================= +// Hosted panel (aura-only; omp's `stats` stays fully local — see docs/aura/FORK.md) +// ============================================================================= + +/** Batch cap on the ingest worker's side (workers/stats/contract.ts, elide-cloud). */ +const STATS_PUSH_BATCH_LIMIT = 500; + +function toStatsRecord(m: MessageStats): Record { + return { + sessionFile: m.sessionFile, + entryId: m.entryId, + folder: m.folder, + model: m.model, + provider: m.provider, + api: m.api, + timestamp: m.timestamp, + duration: m.duration, + ttft: m.ttft, + stopReason: m.stopReason, + errorMessage: m.errorMessage, + usage: { + input: m.usage.input, + output: m.usage.output, + cacheRead: m.usage.cacheRead, + cacheWrite: m.usage.cacheWrite, + totalTokens: m.usage.totalTokens, + premiumRequests: m.usage.premiumRequests, + }, + cost: m.usage.cost, + agentType: m.agentType, + }; +} + +/** + * Push the most recent local records to the Aura ingest endpoint and, on success, open the + * hosted panel instead of starting a local server. Returns `false` for every reason the caller + * should fall back to the existing fully-local flow: `cloud.stats.enabled` is off (the default — + * this ships session history outward, which is opt-in), Aura is not configured at all, the user + * has not run `aura account login`, or the push itself failed. None of those are treated as a + * hard error — the local dashboard has always worked without any of this and must keep working. + */ +async function tryOpenHostedPanel(): Promise { + const settings = await Settings.init(); + const deployment = resolveAuraDeployment({ env: process.env }); + const switches = readCloudSwitches(settings); + if (!(switches.stats ?? false)) return false; + + const narrowed = auraDeploymentFor("stats", deployment, switches); + const authOrigin = resolveServiceEndpoint("auth", { + deployment: auraDeploymentFor("account", deployment, switches), + })?.url; + const ingestUrl = resolveServiceEndpoint("stats", { deployment: narrowed })?.url; + const panelUrl = resolveStatsPanelUrl(deployment.domain); + if (!authOrigin || !ingestUrl || !panelUrl) return false; + + let token: string; + try { + const store = await AuraTokenStore.open(); + const manager = new TokenManager({ authOrigin, store }); + token = (await manager.getAccessToken()).value; + } catch (error) { + if (isAuraCloudError(error) && (error.code === "login_required" || error.code === "relogin_required")) { + console.log(chalk.dim("Hosted panel is enabled but you are not signed in — run `aura account login`.")); + console.log(chalk.dim("Falling back to the local dashboard.\n")); + } else { + console.log(chalk.yellow(`Could not reach Aura (${error instanceof Error ? error.message : String(error)}).`)); + console.log(chalk.dim("Falling back to the local dashboard.\n")); + } + return false; + } + + const { getRecentRequests } = await import("@oh-my-pi/omp-stats"); + const recent = await getRecentRequests(STATS_PUSH_BATCH_LIMIT); + if (recent.length > 0) { + try { + const response = await fetch(`${ingestUrl}/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ records: recent.map(toStatsRecord) }), + }); + if (!response.ok) { + console.log(chalk.yellow(`Push to the hosted panel failed (HTTP ${response.status}).`)); + console.log(chalk.dim("Falling back to the local dashboard.\n")); + return false; + } + const result = (await response.json()) as { inserted?: number }; + console.log(chalk.green(`Pushed ${result.inserted ?? 0} record(s) to the hosted panel.`)); + } catch (error) { + console.log( + chalk.yellow( + `Push to the hosted panel failed (${error instanceof Error ? error.message : String(error)}).`, + ), + ); + console.log(chalk.dim("Falling back to the local dashboard.\n")); + return false; + } + } + + console.log(chalk.green(`Hosted dashboard: ${panelUrl}`)); + // Interim auth handoff: the CLI already holds a valid token; the panel does not yet have a + // session of its own to hand it to, so it rides in the fragment (never sent to the server, + // never logged) rather than a query param. The client-side consumption of this fragment is + // not wired up yet — see elide-cloud's workers/stats-panel/public/README.md. + openPath(`${panelUrl}#token=${encodeURIComponent(token)}`); + return true; +} + // ============================================================================= // Command Handler // ============================================================================= @@ -135,6 +254,11 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise { return; } + if (await tryOpenHostedPanel()) { + closeDb(); + return; + } + // Start the dashboard server const { port } = await startServer(cmd.port); console.log(chalk.green(`Dashboard available at: http://localhost:${port}`)); diff --git a/packages/coding-agent/src/cloud/deployment.ts b/packages/coding-agent/src/cloud/deployment.ts index 11fdcf803fe..b476f02878f 100644 --- a/packages/coding-agent/src/cloud/deployment.ts +++ b/packages/coding-agent/src/cloud/deployment.ts @@ -73,11 +73,12 @@ export interface AuraDeployment { readonly telemetryBaseUrl?: ResolvedEndpoint; readonly qaEndpoint?: ResolvedEndpoint; readonly collabOrigin?: ResolvedEndpoint; + readonly statsIngestBaseUrl?: ResolvedEndpoint; readonly distribution?: AuraDistributionEndpoints; } /** Surfaces that resolve a single endpoint through {@link resolveServiceEndpoint}. */ -export type AuraServiceSurface = "auth" | "sync" | "broker" | "gateway" | "telemetry" | "qa" | "collab"; +export type AuraServiceSurface = "auth" | "sync" | "broker" | "gateway" | "telemetry" | "qa" | "collab" | "stats"; // ═══════════════════════════════════════════════════════════════════════════ // Errors @@ -123,12 +124,13 @@ const MAX_HOSTNAME_LENGTH = 253; const MAX_LABEL_LENGTH = 63; /** - * Longest label this module ever prefixes to `AURA_DOMAIN` (`telemetry.`, `downloads.`). + * Longest label this module ever prefixes to `AURA_DOMAIN` (`stats-ingest.`, ahead of + * `telemetry.` and `downloads.`). * * The 253-octet DNS bound applies to the *derived* host, not to the configured domain, so the * usable domain budget is shorter than 253 by exactly this much. */ -const LONGEST_DERIVED_PREFIX = "telemetry.".length; +const LONGEST_DERIVED_PREFIX = "stats-ingest.".length; /** Everything a DNS name may contain once it is already lowercase ASCII. */ const DOMAIN_CHARSET = /^[a-z0-9.-]+$/; @@ -295,6 +297,11 @@ function derive(domain: string) { telemetry: `https://telemetry.${domain}`, qa: `https://qa.${domain}/v1/grievances`, collab: `https://collab.${domain}`, + stats: `https://stats-ingest.${domain}`, + // Where the CLI opens a browser, not an API base — see resolveStatsPanelUrl. Not a + // {@link AuraServiceSurface}: nothing ever attaches a bearer or a per-call/persisted/ + // legacy override to a browser-navigation target the way it would to an API host. + statsPanel: `https://stats.${domain}`, distApi: `https://api.${domain}/v1/distribution`, distDownload: `https://downloads.${domain}`, distJwks: `https://api.${domain}/.well-known/distribution-jwks.json`, @@ -341,6 +348,7 @@ export function resolveAuraDeployment(input: { env: EnvView }): AuraDeployment { telemetryBaseUrl?: ResolvedEndpoint; qaEndpoint?: ResolvedEndpoint; collabOrigin?: ResolvedEndpoint; + statsIngestBaseUrl?: ResolvedEndpoint; distribution?: AuraDistributionEndpoints; } = {}; @@ -352,6 +360,7 @@ export function resolveAuraDeployment(input: { env: EnvView }): AuraDeployment { assign(deployment, "telemetryBaseUrl", tier("AURA_TELEMETRY_URL", BASE_RULES, derived?.telemetry)); assign(deployment, "qaEndpoint", tier("AURA_QA_URL", BASE_RULES, derived?.qa)); assign(deployment, "collabOrigin", tier("AURA_COLLAB_ORIGIN", COLLAB_RULES, derived?.collab)); + assign(deployment, "statsIngestBaseUrl", tier("AURA_STATS_URL", BASE_RULES, derived?.stats)); const apiBaseUrl = tier("AURA_DIST_API_URL", BASE_RULES, derived?.distApi); const downloadBaseUrl = tier("AURA_DIST_DOWNLOAD_URL", BASE_RULES, derived?.distDownload); @@ -389,6 +398,7 @@ const SURFACE_FIELD: Readonly> telemetry: "telemetryBaseUrl", qa: "qaEndpoint", collab: "collabOrigin", + stats: "statsIngestBaseUrl", }; /** Non-empty explicit value, tagged with the tier it came from. Never re-validated. */ @@ -431,6 +441,20 @@ export function resolveServiceEndpoint( return call ?? setting ?? aura ?? legacy; } +/** + * Where the CLI opens a browser to view the observability panel, or `undefined` when there is + * nothing to point at. + * + * Deliberately not a {@link resolveServiceEndpoint} surface: a browser-navigation target carries + * no bearer token, no per-call/persisted/legacy override, and no exact-URL variable — only + * `AURA_DOMAIN` (via {@link resolveAuraDeployment}'s `domain` field) says where it is. Callers + * still gate this behind `cloud.stats.enabled` themselves, the same as every other consumer of a + * narrowed {@link auraDeploymentFor} view. + */ +export function resolveStatsPanelUrl(domain: string | undefined): string | undefined { + return domain === undefined ? undefined : derive(domain).statsPanel; +} + // ═══════════════════════════════════════════════════════════════════════════ // Cloud switches // ═══════════════════════════════════════════════════════════════════════════ @@ -454,7 +478,8 @@ export type CloudConsumer = | "share" | "distribution" | "runtimeMirror" - | "catalogMirror"; + | "catalogMirror" + | "stats"; /** The settings path that owns each consumer's switch. */ export const CLOUD_CONSUMER_SETTINGS = { @@ -469,6 +494,7 @@ export const CLOUD_CONSUMER_SETTINGS = { distribution: "cloud.distribution.enabled", runtimeMirror: "cloud.runtimeMirror.enabled", catalogMirror: "cloud.catalogMirror.enabled", + stats: "cloud.stats.enabled", } as const satisfies Readonly>; /** A `cloud.*.enabled` settings path. */ @@ -477,9 +503,10 @@ export type CloudSwitchPath = (typeof CLOUD_CONSUMER_SETTINGS)[CloudConsumer]; /** * Defaults, mirroring the schema. * - * Gateway, settings sync and telemetry are off: each ships data outward that the user has not - * asked to ship. Telemetry in particular stays opt-in after login and after a domain is - * configured — being signed in is not consent to be measured. + * Gateway, settings sync, telemetry and stats are off: each ships data outward that the user has + * not asked to ship. Telemetry and stats in particular stay opt-in after login and after a + * domain is configured — being signed in is not consent to be measured, or to have session + * history pushed to a hosted panel. */ export const CLOUD_SWITCH_DEFAULTS = { account: true, @@ -493,6 +520,7 @@ export const CLOUD_SWITCH_DEFAULTS = { distribution: true, runtimeMirror: true, catalogMirror: true, + stats: false, } as const satisfies Readonly>; /** Per-consumer switch state; an omitted consumer falls back to {@link CLOUD_SWITCH_DEFAULTS}. */ @@ -531,6 +559,7 @@ const CONSUMER_FIELD: Readonly> = { distribution: "distribution", runtimeMirror: "distribution", catalogMirror: "distribution", + stats: "statsIngestBaseUrl", }; /** diff --git a/packages/coding-agent/src/cloud/token-manager.ts b/packages/coding-agent/src/cloud/token-manager.ts index 1d1a6576fa9..4470e538bcd 100644 --- a/packages/coding-agent/src/cloud/token-manager.ts +++ b/packages/coding-agent/src/cloud/token-manager.ts @@ -357,6 +357,24 @@ function requireStringArray(value: unknown): readonly string[] { return value as readonly string[]; } +// RFC 6749 scope-token: 1*NQCHAR, i.e. one or more visible, non-space ASCII characters +// (0x21-0x7E). Mirrors elide-cloud's packages/worker-auth/src/scope.ts#parseScopeClaim exactly — +// the server issues `scope` as one RFC-6749 space-delimited string, not a JSON array. +const SCOPE_TOKEN_RE = /^[\x21-\x7E]+$/; + +function parseScopeClaim(value: unknown): readonly string[] { + if (value === undefined || value === null || value === "") return []; + if (typeof value !== "string") reject(); + const tokens = value.split(" "); + const seen = new Set(); + for (const token of tokens) { + if (token.length === 0 || !SCOPE_TOKEN_RE.test(token)) reject(); + if (seen.has(token)) reject(); + seen.add(token); + } + return tokens; +} + function sameSet(actual: readonly string[], expected: readonly string[]): boolean { if (actual.length !== expected.length) return false; const seen = new Set(actual); @@ -441,14 +459,18 @@ export async function verifyAuraToken( if (expected !== undefined && claim !== expected) reject(); return claim; }; - const orgId = tenancy(claims.org_id, contract.orgId); - const accountId = tenancy(claims.account_id, contract.accountId); - const realmId = tenancy(claims.realm_id, contract.realmId); + // Claim names match elide-cloud's issuer exactly (workers/auth/tokens.ts, + // packages/worker-auth/src/principal.ts): `org`/`act`/`realm`, not `org_id`/`account_id`/ + // `realm_id` — this client predates having a real server to verify claim names against. + const orgId = tenancy(claims.org, contract.orgId); + const accountId = tenancy(claims.act, contract.accountId); + const realmId = tenancy(claims.realm, contract.realmId); const roles = requireStringArray(claims.roles); if (contract.roles !== undefined && !sameList(roles, contract.roles)) reject(); - const scopes = requireStringArray(claims.scopes); + // `scope` (singular) is one RFC-6749 space-delimited string server-side, not a `scopes` array. + const scopes = parseScopeClaim(claims.scope); if (!sameSet(scopes, contract.scopes)) reject(); let deviceId: string | undefined; diff --git a/packages/coding-agent/src/commands/account.ts b/packages/coding-agent/src/commands/account.ts new file mode 100644 index 00000000000..7599e291a75 --- /dev/null +++ b/packages/coding-agent/src/commands/account.ts @@ -0,0 +1,53 @@ +/** + * `aura account` — sign in to (or out of) the Aura account used by + * cloud-backed surfaces (currently: the hosted observability panel). + */ +import { APP_NAME } from "@oh-my-pi/pi-utils"; +import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli"; +import { ACCOUNT_ACTIONS, type AccountAction, type AccountCommandArgs, runAccountCommand } from "../cli/account-cli"; +import { initTheme } from "../modes/theme/theme"; + +export default class Account extends Command { + static description = `Sign in to the ${APP_NAME} account (used by the hosted observability panel)`; + + static args = { + action: Args.string({ + description: "Sub-command", + required: false, + options: [...ACCOUNT_ACTIONS], + }), + }; + + static flags = { + json: Flags.boolean({ description: "Output JSON" }), + label: Flags.string({ description: "Device label shown when approving this login (login)" }), + "no-open": Flags.boolean({ description: "Do not open a browser automatically (login)" }), + force: Flags.boolean({ description: "Sign out locally even if the server logout fails (logout)" }), + }; + + static examples = [ + `# Sign in\n ${APP_NAME} account login`, + `# Sign in without opening a browser\n ${APP_NAME} account login --no-open`, + `# Check who is signed in\n ${APP_NAME} account status`, + `# Sign out\n ${APP_NAME} account logout`, + ]; + + async run(): Promise { + const { args, flags } = await this.parse(Account); + if (!args.action) { + renderCommandHelp(APP_NAME, "account", Account); + return; + } + const cmd: AccountCommandArgs = { + action: args.action as AccountAction, + flags: { + json: flags.json, + label: flags.label, + open: !flags["no-open"], + force: flags.force, + }, + }; + await initTheme(); + await runAccountCommand(cmd); + } +} diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c5843115958..9e926ba970d 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -5686,6 +5686,14 @@ export const SETTINGS_SCHEMA = { /** Aura catalog mirror. */ "cloud.catalogMirror.enabled": { type: "boolean", default: true }, + /** + * Aura-hosted observability panel: pushes locally-computed `stats` usage records to the + * Aura account's ingest endpoint, and points `aura stats` at the hosted panel instead of a + * local server. Off by default — this ships session history outward that the user has not + * asked to ship, the same reasoning as telemetry. + */ + "cloud.stats.enabled": { type: "boolean", default: false }, + // Telemetry: OpenTelemetry export (off by default; OTEL_* env always wins) "telemetry.enabled": { type: "boolean", diff --git a/packages/coding-agent/test/cloud/aura-auth-fixture.ts b/packages/coding-agent/test/cloud/aura-auth-fixture.ts index 22504526fcc..d61747536f8 100644 --- a/packages/coding-agent/test/cloud/aura-auth-fixture.ts +++ b/packages/coding-agent/test/cloud/aura-auth-fixture.ts @@ -126,6 +126,11 @@ export const NINE_SCOPES = [ "usage:write", ] as const; +/** + * Claim names and shapes here match elide-cloud's real issuer exactly (workers/auth/tokens.ts, + * packages/worker-auth/src/principal.ts): `org`/`act`/`realm`, and `scope` as one RFC-6749 + * space-delimited string — not `org_id`/`account_id`/`realm_id`/a `scopes` array. + */ export function userClaims(input: UserClaimInput, overrides: ClaimOverrides = {}): Record { const iat = Math.floor(input.nowMs / 1000); return { @@ -136,11 +141,11 @@ export function userClaims(input: UserClaimInput, overrides: ClaimOverrides = {} exp: iat + (input.lifetimeSec ?? 600), jti: input.jti ?? ulid(`JTI${++jtiCounter}`), principal_type: "user", - org_id: input.orgId, - account_id: input.accountId, - realm_id: input.realmId, + org: input.orgId, + act: input.accountId, + realm: input.realmId, roles: input.roles ?? ["member"], - scopes: input.scopes ?? [...NINE_SCOPES], + scope: (input.scopes ?? [...NINE_SCOPES]).join(" "), device: input.deviceId, ...overrides, }; @@ -167,11 +172,11 @@ export function apiKeyClaims(input: ApiKeyClaimInput, overrides: ClaimOverrides exp: iat + (input.lifetimeSec ?? 600), jti: ulid(`JTIK${++jtiCounter}`), principal_type: "api_key", - org_id: input.orgId, - account_id: input.accountId, - realm_id: input.realmId, + org: input.orgId, + act: input.accountId, + realm: input.realmId, roles: [], - scopes: [...input.scopes], + scope: input.scopes.join(" "), ...overrides, }; } diff --git a/packages/coding-agent/test/cloud/auth.test.ts b/packages/coding-agent/test/cloud/auth.test.ts index ae36bd19b26..393a60e7649 100644 --- a/packages/coding-agent/test/cloud/auth.test.ts +++ b/packages/coding-agent/test/cloud/auth.test.ts @@ -485,7 +485,7 @@ describe("approval", () => { { device: undefined }, { device: "laptop" }, { principal_type: "api_key" }, - { scopes: NINE_SCOPES.slice(1) }, + { scope: NINE_SCOPES.slice(1).join(" ") }, { iss: FOREIGN_ORIGIN }, { aud: "other-cloud" }, { sub: "not-a-ulid" }, diff --git a/packages/coding-agent/test/cloud/deployment.test.ts b/packages/coding-agent/test/cloud/deployment.test.ts index 6dd78f38900..6afbdfcd0b7 100644 --- a/packages/coding-agent/test/cloud/deployment.test.ts +++ b/packages/coding-agent/test/cloud/deployment.test.ts @@ -552,11 +552,12 @@ const CONSUMER_FIELD: Readonly> = { distribution: "distribution", runtimeMirror: "distribution", catalogMirror: "distribution", + stats: "statsIngestBaseUrl", }; /** Consumers that resolve through {@link resolveServiceEndpoint}; the mirrors do not. */ const SURFACE_FOR_CONSUMER: Readonly< - Partial> + Partial> > = { account: "auth", settingsSync: "sync", @@ -566,6 +567,7 @@ const SURFACE_FOR_CONSUMER: Readonly< qa: "qa", collab: "collab", share: "collab", + stats: "stats", }; const ALL_CONSUMERS = Object.keys(CONSUMER_FIELD) as CloudConsumer[]; @@ -585,6 +587,7 @@ describe("cloud switches", () => { distribution: "cloud.distribution.enabled", runtimeMirror: "cloud.runtimeMirror.enabled", catalogMirror: "cloud.catalogMirror.enabled", + stats: "cloud.stats.enabled", }); expect(CLOUD_SWITCH_DEFAULTS).toEqual({ account: true, @@ -598,6 +601,7 @@ describe("cloud switches", () => { distribution: true, runtimeMirror: true, catalogMirror: true, + stats: false, }); }); diff --git a/packages/coding-agent/test/cloud/token-manager.test.ts b/packages/coding-agent/test/cloud/token-manager.test.ts index 80418c5b50e..e67de41ccbe 100644 --- a/packages/coding-agent/test/cloud/token-manager.test.ts +++ b/packages/coding-agent/test/cloud/token-manager.test.ts @@ -326,21 +326,21 @@ describe("shared token verifier — cli_user principal contract", () => { const cases: [string, Record][] = [ ["principal_type api_key substituted for a user", { principal_type: "api_key" }], ["principal_type missing", { principal_type: undefined }], - ["a different org", { org_id: ulid("ORG9") }], - ["a different account", { account_id: ulid("ACCT9") }], - ["a different realm", { realm_id: ulid("REALM9") }], - ["a non-ULID org", { org_id: "org-1" }], + ["a different org", { org: ulid("ORG9") }], + ["a different account", { act: ulid("ACCT9") }], + ["a different realm", { realm: ulid("REALM9") }], + ["a non-ULID org", { org: "org-1" }], ["extra roles", { roles: ["member", "admin"] }], ["missing roles", { roles: undefined }], ["roles that are not an array", { roles: "member" }], ["a missing device", { device: undefined }], ["a non-ULID device", { device: "device-1" }], ["a different device", { device: ulid("DEVCE9") }], - ["eight scopes", { scopes: NINE_SCOPES.slice(1) }], - ["ten scopes", { scopes: [...NINE_SCOPES, "admin:all"] }], - ["nine scopes with one substituted", { scopes: [...NINE_SCOPES.slice(1), "admin:all"] }], - ["nine entries with a duplicate", { scopes: [...NINE_SCOPES.slice(1), NINE_SCOPES[1]] }], - ["scopes as a space-delimited string", { scopes: NINE_SCOPES.join(" ") }], + ["eight scopes", { scope: NINE_SCOPES.slice(1).join(" ") }], + ["ten scopes", { scope: [...NINE_SCOPES, "admin:all"].join(" ") }], + ["nine scopes with one substituted", { scope: [...NINE_SCOPES.slice(1), "admin:all"].join(" ") }], + ["nine entries with a duplicate", { scope: [...NINE_SCOPES.slice(1), NINE_SCOPES[1]].join(" ") }], + ["scope as a JSON array (not an RFC-6749 space-delimited string)", { scope: [...NINE_SCOPES] }], ]; for (const [label, overrides] of cases) { @@ -355,7 +355,7 @@ describe("shared token verifier — cli_user principal contract", () => { test("accepts the nine scopes in any order", async () => { const shuffled = [...NINE_SCOPES].reverse(); - const token = await mintToken(key, userClaims({ ...ids(), nowMs: T0 }, { scopes: shuffled })); + const token = await mintToken(key, userClaims({ ...ids(), nowMs: T0 }, { scope: shuffled.join(" ") })); const verified = await verifyAuraToken(token, userContract(), { keys: staticKeys(key), nowMs: T0 }); expect([...verified.scopes].sort()).toEqual([...NINE_SCOPES].sort()); }); @@ -419,14 +419,14 @@ describe("shared token verifier — imported_api_key principal contract", () => const cases: [string, Record][] = [ ["a subject that is not the receipt's api key id", { sub: ulid("KEY9") }], ["a user principal substituted for an api key", { principal_type: "user" }], - ["a different realm", { realm_id: ulid("REALM9") }], - ["a different org", { org_id: ulid("ORG9") }], - ["a different account", { account_id: ulid("ACCT9") }], + ["a different realm", { realm: ulid("REALM9") }], + ["a different org", { org: ulid("ORG9") }], + ["a different account", { act: ulid("ACCT9") }], ["non-empty roles", { roles: ["member"] }], ["a device binding", { device: DEVICE }], - ["a scope the receipt's surface does not carry", { scopes: [...RECEIPT.scopes, "sync:write"] }], - ["fewer scopes than the receipt's surface", { scopes: [RECEIPT.scopes[0]] }], - ["the nine user scopes", { scopes: [...NINE_SCOPES] }], + ["a scope the receipt's surface does not carry", { scope: [...RECEIPT.scopes, "sync:write"].join(" ") }], + ["fewer scopes than the receipt's surface", { scope: [RECEIPT.scopes[0]].join(" ") }], + ["the nine user scopes", { scope: [...NINE_SCOPES].join(" ") }], ["a foreign issuer", { iss: "https://auth.evil.example" }], ["a foreign audience", { aud: "other-cloud" }], ]; diff --git a/packages/stats/build.ts b/packages/stats/build.ts index c7b535457f3..56fa2d020d2 100644 --- a/packages/stats/build.ts +++ b/packages/stats/build.ts @@ -70,11 +70,11 @@ const indexHtml = ` - AI Usage Statistics + Aura Observability