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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion packages/cli/src/owner-agent/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -95,6 +109,56 @@ export async function introspectOwnerAgentCredential({
};
}

async function checkOwnerAgentControlSurface({
fetchFn,
record,
}: IntrospectOwnerAgentCredentialArgs): Promise<IntrospectionResult> {
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;
Expand Down
46 changes: 34 additions & 12 deletions packages/cli/test/owner-agent-reference-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/test/owner-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down