Files
openclaw/src/agents/execution-auth-binding.ts
Peter Steinberger 96f0983a85 fix(onboarding): skip setup for configured gateways and require inference first (#102883)
* fix(crestodian): keep onboarding RPCs restart-safe

* fix(profiles): isolate approval state migrations

* fix(crestodian): bypass configured gateway setup

* test(crestodian): type onboarding mocks

* fix(onboarding): require inference before Crestodian

* fix(onboarding): enforce verified inference handoff

* fix(macos): reset setup on gateway endpoint edits

* chore(i18n): refresh native source inventory

* fix(gateway): keep socket on request cancellation

* test(packaging): require workspace templates

* fix(onboarding): bind setup to verified inference

* fix(onboarding): align inference gate contracts

* fix(crestodian): classify concurrent policy rejection

* test(crestodian): expect registry restoration

* fix(onboarding): bind setup to configured gateways

* fix(codex): preserve startup phase deadlines

* test(crestodian): match fail-closed policy ordering

* test(onboarding): assert bound gateway handoff

* fix(codex): bind runtime resolution to spawn cwd

* test(crestodian): assert policy rejection order

* fix(cli): preserve gateway routing across restarts

* fix(macos): fail closed during gateway edits

* test(macos): cover gateway route generation races

* chore: keep release notes out of onboarding PR

* fix(ci): refresh onboarding generated checks

* style(swift): align gateway channel formatting

* fix(ci): refresh plugin SDK surface budgets

* fix(ci): resync native string inventory

* refactor(swift): split gateway channel support

* test(doctor): isolate plugin compatibility registry

* test(macos): isolate gateway onboarding fixtures

* test(macos): assert gateway lease health ordering

* fix(codex): reconcile computer-use startup changes
2026-07-11 10:25:14 -07:00

297 lines
10 KiB
TypeScript

import crypto from "node:crypto";
import type { AuthProfileCredential } from "./auth-profiles/types.js";
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
/** Ephemeral proof of the credential or opaque runtime that completed one agent run. */
export type AgentExecutionAuthBinding = {
authProfileId?: string;
/** Exact embedded harness that completed the successful turn, including openclaw. */
agentHarnessId?: string;
/** Non-reversible identity hash; credential material never leaves the runner. */
authFingerprint?: string;
/** Runtime-owned principal/session shape used when credentials are intentionally opaque. */
runtimeOwnerFingerprint?: string;
/** Exact opaque owner, or plugin harness carrying a credential-backed turn. */
runtimeOwnerKind?: OpaqueRuntimeOwnerKind;
/** Exact backend/harness id that owned the successful turn. */
runtimeOwnerId?: string;
/** Exact CLI or plugin-harness implementation used by the successful turn. */
runtimeArtifactFingerprint?: string;
runtimeArtifactId?: string;
/** The prepared CLI bridge used only the selected profile, not ambient CLI auth. */
skipLocalCredential?: true;
};
export type OpaqueRuntimeOwnerKind = "cli-runtime" | "plugin-harness" | "aws-sdk";
// Fingerprints are process-local proofs. Restarting rotates this key and
// invalidates them instead of leaving a reusable offline digest of a secret.
const authBindingFingerprintKey = crypto.randomBytes(32);
function hashAuthBinding(value: unknown): string {
return crypto
.createHmac("sha256", authBindingFingerprintKey)
.update(JSON.stringify(value))
.digest("hex");
}
function normalizeIdentity(value: string | undefined, lowercase = false): string | undefined {
const normalized = value?.trim();
return normalized ? (lowercase ? normalized.toLowerCase() : normalized) : undefined;
}
/**
* Project non-secret profile ownership for runtimes that keep rotating tokens
* behind their own process boundary. An explicitly selected missing profile
* has no owner shape and must never collapse to ambient runtime authority.
*/
export function fingerprintAuthProfileOwnerShape(params: {
profileId: string;
credential: AuthProfileCredential | undefined;
}): string | undefined {
const credential = params.credential;
if (!credential) {
return undefined;
}
switch (credential.type) {
case "api_key":
return hashAuthBinding([
"profile-owner-v1",
params.profileId,
credential.type,
credential.provider,
credential.keyRef ?? null,
normalizeIdentity(credential.email, true) ?? null,
normalizeIdentity(credential.displayName) ?? null,
credential.metadata ?? null,
]);
case "token":
return hashAuthBinding([
"profile-owner-v1",
params.profileId,
credential.type,
credential.provider,
credential.tokenRef ?? null,
normalizeIdentity(credential.email, true) ?? null,
normalizeIdentity(credential.displayName) ?? null,
]);
case "oauth": {
const jwtIdentity = decodeJwtIdentity(credential.idToken);
return hashAuthBinding([
"profile-owner-v1",
params.profileId,
credential.type,
credential.provider,
normalizeIdentity(credential.accountId) ?? jwtIdentity.subject ?? null,
normalizeIdentity(credential.email, true) ?? jwtIdentity.email ?? null,
credential.clientId ?? null,
credential.enterpriseUrl ?? null,
credential.projectId ?? null,
]);
}
}
return undefined;
}
/** Fingerprint the stable owner boundary of a successful opaque runtime turn. */
export function fingerprintOpaqueRuntimeOwner(params: {
kind: OpaqueRuntimeOwnerKind;
runner: "cli" | "embedded";
provider: string;
backendId: string;
backendConfig?: unknown;
authProfileId?: string;
authProfileOwnerFingerprint?: string;
authSource?: string;
skipLocalCredential?: boolean;
runtimeArtifactFingerprint?: string;
}): string | undefined {
const runtimeArtifactFingerprint = params.runtimeArtifactFingerprint;
const authProfileId = normalizeIdentity(params.authProfileId);
if (authProfileId && !params.authProfileOwnerFingerprint) {
return undefined;
}
if (!authProfileId && params.skipLocalCredential) {
return undefined;
}
if (
(params.kind === "cli-runtime" || params.kind === "plugin-harness") &&
!runtimeArtifactFingerprint
) {
return undefined;
}
return hashAuthBinding([
params.kind === "aws-sdk" ? "opaque-runtime-owner-v1" : "opaque-runtime-owner-v2",
params.kind,
params.runner,
params.provider.trim(),
params.backendId,
params.backendConfig ?? null,
authProfileId ?? null,
params.authProfileOwnerFingerprint ?? null,
params.authSource ?? null,
params.skipLocalCredential === true,
runtimeArtifactFingerprint ?? null,
]);
}
/** Fingerprint only AWS SDK owners whose exact credential is observable here. */
export function fingerprintAwsSdkRuntimeOwner(params: {
provider: string;
backendId: string;
auth: ResolvedProviderAuth | null | undefined;
env?: NodeJS.ProcessEnv;
}): string | undefined {
if (params.auth?.mode !== "aws-sdk" || params.auth.apiKey) {
return undefined;
}
const env = params.env ?? process.env;
let owner: unknown;
if (env.AWS_BEARER_TOKEN_BEDROCK?.trim()) {
owner = ["bearer", hashAuthBinding(env.AWS_BEARER_TOKEN_BEDROCK.trim())];
} else if (env.AWS_PROFILE?.trim()) {
// A profile name is not a principal: its role/source/SSO account can change
// without the name changing. Supporting profiles and instance/container
// roles requires provider-owned proof of the resolved account/ARN.
return undefined;
} else if (env.AWS_ACCESS_KEY_ID?.trim() && env.AWS_SECRET_ACCESS_KEY?.trim()) {
owner = [
"access-key",
env.AWS_ACCESS_KEY_ID.trim(),
hashAuthBinding([env.AWS_SECRET_ACCESS_KEY.trim(), env.AWS_SESSION_TOKEN?.trim() ?? null]),
];
} else {
return undefined;
}
return fingerprintOpaqueRuntimeOwner({
kind: "aws-sdk",
runner: "embedded",
provider: params.provider,
backendId: params.backendId,
authSource: hashAuthBinding([params.auth.source, owner]),
});
}
function decodeJwtIdentity(token: string | undefined): { subject?: string; email?: string } {
const payload = token?.split(".")[1];
if (!payload) {
return {};
}
try {
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
sub?: unknown;
email?: unknown;
};
return {
...(typeof claims.sub === "string" && normalizeIdentity(claims.sub)
? { subject: normalizeIdentity(claims.sub) }
: {}),
...(typeof claims.email === "string" && normalizeIdentity(claims.email, true)
? { email: normalizeIdentity(claims.email, true) }
: {}),
};
} catch {
return {};
}
}
/** Fingerprint the exact active credential owner used by one execution. */
export function fingerprintAuthProfileCredential(params: {
profileId: string;
credential: AuthProfileCredential;
}): string | undefined {
const credential = params.credential;
switch (credential.type) {
case "api_key": {
if (!credential.key) {
return undefined;
}
return hashAuthBinding([
"api_key",
params.profileId,
credential.provider,
credential.key,
credential.keyRef ?? null,
credential.email ?? null,
credential.displayName ?? null,
credential.metadata ?? null,
]);
}
case "token": {
if (!credential.token) {
return undefined;
}
return hashAuthBinding([
"token",
params.profileId,
credential.provider,
credential.token,
credential.tokenRef ?? null,
credential.email ?? null,
credential.displayName ?? null,
]);
}
case "oauth": {
const jwtIdentity = decodeJwtIdentity(credential.idToken);
const accountId = normalizeIdentity(credential.accountId) ?? jwtIdentity.subject;
const email = normalizeIdentity(credential.email, true) ?? jwtIdentity.email;
const stableIdentity = accountId ?? email;
const opaqueIdentity = stableIdentity
? null
: [credential.access, credential.refresh, credential.idToken ?? null];
if (!stableIdentity && !credential.access && !credential.refresh && !credential.idToken) {
return undefined;
}
return hashAuthBinding([
"oauth",
params.profileId,
credential.provider,
credential.clientId ?? null,
accountId ?? null,
email ?? null,
credential.enterpriseUrl ?? null,
credential.projectId ?? null,
opaqueIdentity,
]);
}
}
return undefined;
}
/** Fingerprint a profile after materializing its selected SecretRef value. */
export function fingerprintResolvedAuthProfileCredential(params: {
profileId: string;
credential: AuthProfileCredential;
resolvedAuth: ResolvedProviderAuth | null | undefined;
}): string | undefined {
const credential = params.credential;
if (credential.type === "oauth") {
return fingerprintAuthProfileCredential({ profileId: params.profileId, credential });
}
if (params.resolvedAuth && params.resolvedAuth.profileId !== params.profileId) {
return undefined;
}
const inlineValue = credential.type === "api_key" ? credential.key : credential.token;
const resolvedValue = params.resolvedAuth?.apiKey ?? inlineValue;
if (!resolvedValue) {
return undefined;
}
return fingerprintAuthProfileCredential({
profileId: params.profileId,
credential:
credential.type === "api_key"
? { ...credential, key: resolvedValue }
: { ...credential, token: resolvedValue },
});
}
/** Fingerprint an ambient/config/env credential that was actually selected. */
export function fingerprintResolvedProviderAuth(
auth: ResolvedProviderAuth | null | undefined,
): string | undefined {
if (!auth?.apiKey) {
return undefined;
}
return hashAuthBinding(["resolved", auth.profileId ?? null, auth.source, auth.mode, auth.apiKey]);
}