diff --git a/packages/cli/src/owner-agent/lifecycle.ts b/packages/cli/src/owner-agent/lifecycle.ts index 3f8a190d3..bbe385deb 100644 --- a/packages/cli/src/owner-agent/lifecycle.ts +++ b/packages/cli/src/owner-agent/lifecycle.ts @@ -11,12 +11,18 @@ import { readFile } from "node:fs/promises"; import { OwnerAgentError } from "./errors.ts"; type FetchFn = typeof fetch; +const TRAILING_SLASHES_RE = /\/+$/; export interface OwnerAgentCredentialRecord { access_token?: string; - credential?: { access_token?: string }; + client_id?: string; + credential?: { access_token?: string; expires_at?: string | null; scope?: string | null }; + expires_at?: string | null; introspection_endpoint?: string; + pdpp_token_kind?: string; registration_client_uri?: string; + resource?: string; + scope?: string | null; [key: string]: unknown; } @@ -68,6 +74,14 @@ export async function introspectOwnerAgentCredential({ throw new OwnerAgentError("request_failed", `Introspection request failed: ${(error as Error).message}.`); } if (!response.ok) { + // `/introspect` is AS↔RS infrastructure and, after the authorization + // hardening stack, requires the confidential RS caller credentials. An + // owner agent must not be given those credentials. When the deployment + // exposes the owner-agent control surface, use its bearer-authenticated + // capability document as the owner credential's liveness check instead. + if ((response.status === 401 || response.status === 403) && record.resource) { + return await checkOwnerAgentControlSurface({ fetchFn, record }); + } throw new OwnerAgentError("introspection_failed", `Introspection failed with HTTP ${response.status}.`); } let json: { @@ -95,6 +109,56 @@ export async function introspectOwnerAgentCredential({ }; } +async function checkOwnerAgentControlSurface({ + fetchFn, + record, +}: IntrospectOwnerAgentCredentialArgs): Promise { + const token = getOwnerAgentAccessToken(record); + const resource = record.resource?.replace(TRAILING_SLASHES_RE, ""); + if (!(token && resource)) { + throw new OwnerAgentError("credential_invalid", "Stored credential is missing an owner control resource."); + } + let response: Response; + try { + response = await fetchFn(`${resource}/v1/owner/control`, { + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); + } catch (error) { + // biome-ignore lint/style/useErrorCause: OwnerAgentError has no cause slot; the original error's message is already folded into this one. + throw new OwnerAgentError("request_failed", `Owner-agent status request failed: ${(error as Error).message}.`); + } + if (response.status === 401 || response.status === 403) { + return { + active: false, + client_id: typeof record.client_id === "string" ? record.client_id : null, + exp: credentialExpiry(record), + scope: typeof record.scope === "string" ? record.scope : null, + sub: null, + token_kind: typeof record.pdpp_token_kind === "string" ? record.pdpp_token_kind : "owner", + }; + } + if (!response.ok) { + throw new OwnerAgentError("introspection_failed", `Owner-agent status failed with HTTP ${response.status}.`); + } + return { + active: true, + client_id: typeof record.client_id === "string" ? record.client_id : null, + exp: credentialExpiry(record), + scope: typeof record.scope === "string" ? record.scope : null, + sub: null, + token_kind: typeof record.pdpp_token_kind === "string" ? record.pdpp_token_kind : "owner", + }; +} + +function credentialExpiry(record: OwnerAgentCredentialRecord): number | null { + const value = record.expires_at ?? record.credential?.expires_at ?? null; + if (typeof value !== "string") { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? Math.floor(timestamp / 1000) : null; +} + interface RevokeOwnerAgentCredentialArgs { fetchFn: FetchFn; ownerSessionCookie: string | undefined; diff --git a/packages/cli/test/owner-agent-reference-smoke.test.ts b/packages/cli/test/owner-agent-reference-smoke.test.ts index ac3bec299..dfde1b7d3 100644 --- a/packages/cli/test/owner-agent-reference-smoke.test.ts +++ b/packages/cli/test/owner-agent-reference-smoke.test.ts @@ -10,8 +10,10 @@ import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { registerConnector } from "../../../reference-implementation/server/auth.ts"; import { startServer } from "../../../reference-implementation/server/index.ts"; import { ingestRecord } from "../../../reference-implementation/server/records.ts"; +import { createRequestConnectorInstanceStore } from "../../../reference-implementation/server/request-store-factories.ts"; import { runOwnerAgent } from "../src/owner-agent/command.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -163,19 +165,39 @@ function loadNorthstarManifest() { } async function seedNorthstar(nativeManifest) { - await ingestRecord(nativeManifest.storage_binding.connector_id, { - stream: "pay_statements", - key: "ps_owner_agent_cli_smoke_1", - data: { - statement_id: "ps_owner_agent_cli_smoke_1", - employer: "Northstar HR", - gross_pay: 5400, - net_pay: 3912, - currency: "USD", - employee_id: "emp_cli_smoke", - }, - emitted_at: "2026-05-31T00:00:00Z", + await registerConnector({ + connector_key: nativeManifest.storage_binding.connector_id, + declaration_version: nativeManifest.source_declaration.declaration_version, + name: nativeManifest.name, + source_declaration: nativeManifest.source_declaration, + streams: nativeManifest.streams, + version: nativeManifest.version, + }); + const instance = await createRequestConnectorInstanceStore().ensureDefaultAccountConnection({ + connectorId: nativeManifest.storage_binding.connector_id, + displayName: "Northstar HR", + now: "2026-05-31T00:00:00Z", + ownerSubjectId: TEST_SUBJECT, }); + await ingestRecord( + { + connector_id: nativeManifest.storage_binding.connector_id, + connector_instance_id: instance.connectorInstanceId, + }, + { + stream: "pay_statements", + key: "ps_owner_agent_cli_smoke_1", + data: { + statement_id: "ps_owner_agent_cli_smoke_1", + employer: "Northstar HR", + gross_pay: 5400, + net_pay: 3912, + currency: "USD", + employee_id: "emp_cli_smoke", + }, + emitted_at: "2026-05-31T00:00:00Z", + } + ); } async function approveDeviceCode(asUrl, sessionCookie, userCode) { diff --git a/packages/cli/test/owner-agent.test.ts b/packages/cli/test/owner-agent.test.ts index 8d1cee729..42d7e45e6 100644 --- a/packages/cli/test/owner-agent.test.ts +++ b/packages/cli/test/owner-agent.test.ts @@ -15,6 +15,8 @@ import { discoverOwnerAgentProfile, normalizeEntrypointUrl } from "../src/owner- import { OwnerAgentError } from "../src/owner-agent/errors.ts"; const SECRET = "super-secret-owner-bearer-value"; +const ACTIVE_STATUS_RE = /active: true/; +const OWNER_TOKEN_KIND_RE = /token kind: owner/; const REG_TOKEN = "reg-access-token-value"; function capture() { @@ -476,6 +478,31 @@ test("status returns nonzero when token is inactive (revoked)", async () => { }); }); +test("status falls back to the bearer-authenticated owner control surface", async () => { + await withTmpHome(async (home) => { + await seedCredential(home); + const captured = capture(); + let controlAuth: string | null = null; + const fetch = makeFetch([ + { method: "POST", match: "/introspect", status: 401, body: { error: { code: "context.authentication_failed" } } }, + { + method: "GET", + match: "/v1/owner/control", + handler: ({ opts }) => { + controlAuth = opts.headers?.Authorization ?? null; + return jsonResponse(200, { object: "owner_agent_control_surface" }); + }, + }, + ]); + const code = await runOwnerAgent(["status", "--entrypoint", "https://ref.test"], captured.io, { fetch, home }); + assert.equal(code, 0); + assert.match(captured.stdout, ACTIVE_STATUS_RE); + assert.match(captured.stdout, OWNER_TOKEN_KIND_RE); + assert.equal(controlAuth, `Bearer ${SECRET}`); + assert.doesNotMatch(captured.stdout, new RegExp(SECRET)); + }); +}); + test("status without a stored credential reports not_onboarded", async () => { await withTmpHome(async (home) => { const captured = capture();