mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 15:31:16 +00:00
* 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
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
/**
|
|
* Builds auth-state epochs for CLI-backed runtimes so reusable sessions reset
|
|
* when the owning local credential identity changes.
|
|
*/
|
|
import crypto from "node:crypto";
|
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { ensureAuthProfileStore, loadAuthProfileStoreForRuntime } from "./auth-profiles/store.js";
|
|
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
|
|
import { resolveCliBackendConfig } from "./cli-backends.js";
|
|
import {
|
|
readClaudeCliCredentialsCached,
|
|
readCodexCliCredentialsCached,
|
|
readGeminiCliCredentialsCached,
|
|
type ClaudeCliCredential,
|
|
type CodexCliCredential,
|
|
type GeminiCliCredential,
|
|
} from "./cli-credentials.js";
|
|
import {
|
|
resolveCliExecutableIdentity,
|
|
type CliExecutableIdentity,
|
|
} from "./cli-executable-identity.js";
|
|
import {
|
|
fingerprintAuthProfileCredential,
|
|
fingerprintAuthProfileOwnerShape,
|
|
fingerprintOpaqueRuntimeOwner,
|
|
fingerprintResolvedAuthProfileCredential,
|
|
} from "./execution-auth-binding.js";
|
|
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
|
|
|
|
type CliAuthEpochDeps = {
|
|
readClaudeCliCredentialsCached: typeof readClaudeCliCredentialsCached;
|
|
readCodexCliCredentialsCached: typeof readCodexCliCredentialsCached;
|
|
readGeminiCliCredentialsCached: typeof readGeminiCliCredentialsCached;
|
|
ensureAuthProfileStore: typeof ensureAuthProfileStore;
|
|
loadAuthProfileStoreForRuntime: typeof loadAuthProfileStoreForRuntime;
|
|
};
|
|
|
|
const defaultCliAuthEpochDeps: CliAuthEpochDeps = {
|
|
readClaudeCliCredentialsCached,
|
|
readCodexCliCredentialsCached,
|
|
readGeminiCliCredentialsCached,
|
|
ensureAuthProfileStore,
|
|
loadAuthProfileStoreForRuntime,
|
|
};
|
|
|
|
const cliAuthEpochDeps: CliAuthEpochDeps = { ...defaultCliAuthEpochDeps };
|
|
|
|
/** Version salt for CLI auth epoch encoding semantics. */
|
|
export const CLI_AUTH_EPOCH_VERSION = 6;
|
|
|
|
const GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
|
|
|
|
/** Overrides credential readers for auth-epoch unit tests. */
|
|
export function setCliAuthEpochTestDeps(overrides: Partial<CliAuthEpochDeps>): void {
|
|
Object.assign(cliAuthEpochDeps, overrides);
|
|
}
|
|
|
|
/** Restores default credential readers after auth-epoch unit tests. */
|
|
export function resetCliAuthEpochTestDeps(): void {
|
|
Object.assign(cliAuthEpochDeps, defaultCliAuthEpochDeps);
|
|
}
|
|
|
|
function hashCliAuthEpochPart(value: string): string {
|
|
// Epoch hashes detect local auth-state changes; they are not password
|
|
// storage or credential verification.
|
|
// codeql[js/insufficient-password-hash]
|
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function encodeUnknown(value: unknown): string {
|
|
return JSON.stringify(value ?? null);
|
|
}
|
|
|
|
function encodeOAuthIdentity(credential: {
|
|
type: "oauth";
|
|
provider: string;
|
|
clientId?: string;
|
|
email?: string;
|
|
enterpriseUrl?: string;
|
|
projectId?: string;
|
|
accountId?: string;
|
|
}): string {
|
|
return JSON.stringify([
|
|
"oauth",
|
|
credential.provider,
|
|
credential.clientId ?? null,
|
|
credential.email ?? null,
|
|
credential.enterpriseUrl ?? null,
|
|
credential.projectId ?? null,
|
|
credential.accountId ?? null,
|
|
]);
|
|
}
|
|
|
|
function encodeClaudeCredential(credential: ClaudeCliCredential): string {
|
|
// Identity-only hashing for both OAuth and token Claude CLI credentials.
|
|
// The Claude CLI keychain rewrite is not atomic: a token rotation can
|
|
// briefly produce a partial read where `refreshToken` is missing, and the
|
|
// parser falls back to a token-shaped credential. With the previous
|
|
// token-inclusive hash, that transient race flipped the auth-epoch and
|
|
// forced a session reset on every rotation. Routing both branches through
|
|
// `encodeOAuthIdentity` collapses partial reads and rotations onto the
|
|
// same provider-keyed identity hash, while a real account switch would
|
|
// still surface as different identity fields. Fixes #74312.
|
|
return encodeOAuthIdentity({
|
|
type: "oauth",
|
|
provider: credential.provider,
|
|
});
|
|
}
|
|
|
|
function encodeCodexCredential(credential: CodexCliCredential): string {
|
|
return encodeOAuthIdentity(credential);
|
|
}
|
|
|
|
function encodeGeminiCredential(credential: GeminiCliCredential): string {
|
|
// Delegate to the shared OAuth-identity encoder. The Gemini CLI reader
|
|
// lifts the Google-account identity (sub, email) off the openid id_token
|
|
// onto the credential, so the encoder fingerprints the user through stable,
|
|
// non-secret identity fields — matching the Claude/Codex OAuth contract.
|
|
// When the id_token is absent (older logins, scope omitted), the encoder
|
|
// falls back to a provider-keyed constant, the same identity-less behavior
|
|
// the Claude CLI OAuth branch tolerates.
|
|
return encodeOAuthIdentity(credential);
|
|
}
|
|
|
|
function encodeAuthProfileCredential(credential: AuthProfileCredential): string {
|
|
switch (credential.type) {
|
|
case "api_key":
|
|
return JSON.stringify([
|
|
"api_key",
|
|
credential.provider,
|
|
credential.key ?? null,
|
|
encodeUnknown(credential.keyRef),
|
|
credential.email ?? null,
|
|
credential.displayName ?? null,
|
|
encodeUnknown(credential.metadata),
|
|
]);
|
|
case "token":
|
|
if (credential.tokenRef !== undefined) {
|
|
// When a token profile has a stable account/ref identity, token
|
|
// material is a refreshable secret rather than the session owner.
|
|
// Plain token-only profiles still hash the token below so manual token
|
|
// replacement keeps invalidating reusable sessions.
|
|
return JSON.stringify([
|
|
"token-identity",
|
|
credential.provider,
|
|
encodeUnknown(credential.tokenRef),
|
|
credential.email ?? null,
|
|
credential.displayName ?? null,
|
|
]);
|
|
}
|
|
return JSON.stringify([
|
|
"token",
|
|
credential.provider,
|
|
credential.token ?? null,
|
|
encodeUnknown(credential.tokenRef),
|
|
credential.email ?? null,
|
|
credential.displayName ?? null,
|
|
]);
|
|
case "oauth":
|
|
return encodeOAuthIdentity(credential);
|
|
}
|
|
throw new Error("Unsupported auth profile credential type");
|
|
}
|
|
|
|
function hasOAuthAccountIdentity(credential: AuthProfileCredential): boolean {
|
|
return (
|
|
credential.type === "oauth" &&
|
|
(normalizeOptionalString(credential.accountId) !== undefined ||
|
|
normalizeOptionalString(credential.email) !== undefined)
|
|
);
|
|
}
|
|
|
|
function encodeAuthProfileEpochPart(
|
|
authProfileId: string,
|
|
credential: AuthProfileCredential,
|
|
): string {
|
|
const credentialHash = hashCliAuthEpochPart(encodeAuthProfileCredential(credential));
|
|
if (hasOAuthAccountIdentity(credential) && credential.provider !== GEMINI_CLI_PROVIDER_ID) {
|
|
return `profile:oauth-identity:${credentialHash}`;
|
|
}
|
|
return `profile:${authProfileId}:${credentialHash}`;
|
|
}
|
|
|
|
function getLocalCliCredentialFingerprint(provider: string): string | undefined {
|
|
switch (provider) {
|
|
case "claude-cli": {
|
|
const credential = cliAuthEpochDeps.readClaudeCliCredentialsCached({
|
|
ttlMs: 5000,
|
|
allowKeychainPrompt: false,
|
|
});
|
|
// Keep true credential absence absent so logout/removal invalidates
|
|
// reusable sessions. The 5s credential cache still masks transient
|
|
// null reads immediately after a successful read.
|
|
return credential ? hashCliAuthEpochPart(encodeClaudeCredential(credential)) : undefined;
|
|
}
|
|
case "codex-cli": {
|
|
const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({
|
|
ttlMs: 5000,
|
|
allowKeychainPrompt: false,
|
|
});
|
|
return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined;
|
|
}
|
|
case "google-gemini-cli": {
|
|
const credential = cliAuthEpochDeps.readGeminiCliCredentialsCached({
|
|
ttlMs: 5000,
|
|
});
|
|
return credential ? hashCliAuthEpochPart(encodeGeminiCredential(credential)) : undefined;
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getLocalCliCredential(provider: string): AuthProfileCredential | undefined {
|
|
switch (provider) {
|
|
case "claude-cli":
|
|
return (
|
|
cliAuthEpochDeps.readClaudeCliCredentialsCached({
|
|
ttlMs: 0,
|
|
allowKeychainPrompt: false,
|
|
}) ?? undefined
|
|
);
|
|
case "codex-cli":
|
|
return (
|
|
cliAuthEpochDeps.readCodexCliCredentialsCached({
|
|
ttlMs: 0,
|
|
allowKeychainPrompt: false,
|
|
}) ?? undefined
|
|
);
|
|
case "google-gemini-cli":
|
|
return cliAuthEpochDeps.readGeminiCliCredentialsCached({ ttlMs: 0 }) ?? undefined;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getAuthProfileCredential(
|
|
store: AuthProfileStore,
|
|
authProfileId: string | undefined,
|
|
): AuthProfileCredential | undefined {
|
|
if (!authProfileId) {
|
|
return undefined;
|
|
}
|
|
return store.profiles[authProfileId];
|
|
}
|
|
|
|
/** Resolves the stable auth epoch hash for a CLI runtime/provider session. */
|
|
export async function resolveCliAuthEpoch(params: {
|
|
provider: string;
|
|
agentDir?: string;
|
|
authProfileId?: string;
|
|
skipLocalCredential?: boolean;
|
|
}): Promise<string | undefined> {
|
|
const provider = params.provider.trim();
|
|
const authProfileId = normalizeOptionalString(params.authProfileId);
|
|
const parts: string[] = [];
|
|
|
|
if (params.skipLocalCredential !== true) {
|
|
const localFingerprint = getLocalCliCredentialFingerprint(provider);
|
|
if (localFingerprint) {
|
|
parts.push(`local:${provider}:${localFingerprint}`);
|
|
}
|
|
}
|
|
|
|
if (authProfileId) {
|
|
const store = cliAuthEpochDeps.loadAuthProfileStoreForRuntime(params.agentDir, {
|
|
readOnly: true,
|
|
allowKeychainPrompt: false,
|
|
});
|
|
const credential = getAuthProfileCredential(store, authProfileId);
|
|
if (credential) {
|
|
parts.push(encodeAuthProfileEpochPart(authProfileId, credential));
|
|
}
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
return undefined;
|
|
}
|
|
return hashCliAuthEpochPart(parts.join("\n"));
|
|
}
|
|
|
|
/**
|
|
* Strict credential-owner proof for a verified inference turn. Unlike the
|
|
* reusable-session epoch, identity-less OAuth tokens intentionally invalidate
|
|
* on rotation because accepting an unknown replacement could cross accounts.
|
|
*/
|
|
export function resolveCliAuthBindingFingerprint(params: {
|
|
provider: string;
|
|
config: OpenClawConfig;
|
|
agentDir?: string;
|
|
authProfileId?: string;
|
|
/** Exact selected profile material actually forwarded to this execution. */
|
|
resolvedAuth?: ResolvedProviderAuth;
|
|
skipLocalCredential?: boolean;
|
|
}): string | undefined {
|
|
const provider = params.provider.trim();
|
|
const authProfileId = normalizeOptionalString(params.authProfileId);
|
|
const parts: string[] = [];
|
|
const localCredential = params.skipLocalCredential ? undefined : getLocalCliCredential(provider);
|
|
if (localCredential) {
|
|
const fingerprint = fingerprintAuthProfileCredential({
|
|
profileId: `local:${provider}`,
|
|
credential: localCredential,
|
|
});
|
|
if (!fingerprint) {
|
|
return undefined;
|
|
}
|
|
parts.push(`local:${fingerprint}`);
|
|
}
|
|
if (authProfileId) {
|
|
const store = cliAuthEpochDeps.ensureAuthProfileStore(params.agentDir, {
|
|
config: params.config,
|
|
readOnly: true,
|
|
allowKeychainPrompt: false,
|
|
externalCliProviderIds: [provider],
|
|
});
|
|
const storedCredential = store.profiles[authProfileId];
|
|
if (!storedCredential) {
|
|
return undefined;
|
|
}
|
|
const fingerprint = fingerprintResolvedAuthProfileCredential({
|
|
profileId: authProfileId,
|
|
credential: storedCredential,
|
|
resolvedAuth: params.resolvedAuth,
|
|
});
|
|
if (!fingerprint) {
|
|
return undefined;
|
|
}
|
|
parts.push(`profile:${fingerprint}`);
|
|
}
|
|
return parts.length > 0
|
|
? hashCliAuthEpochPart(JSON.stringify(["strict-execution-v1", provider, parts]))
|
|
: undefined;
|
|
}
|
|
|
|
type CliRuntimeArtifactFingerprintParams = {
|
|
provider: string;
|
|
config: OpenClawConfig;
|
|
agentId?: string;
|
|
runtimeArtifactId?: string;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
executableIdentity?: CliExecutableIdentity;
|
|
};
|
|
|
|
/** Hash the exact executable plus backend-owned package implementation tree. */
|
|
export function fingerprintCliRuntimeArtifact(params: {
|
|
provider: string;
|
|
backendId: string;
|
|
executableIdentity: CliExecutableIdentity;
|
|
}): string {
|
|
return hashCliAuthEpochPart(
|
|
JSON.stringify([
|
|
"cli-runtime-artifact-v1",
|
|
params.provider.trim(),
|
|
params.backendId,
|
|
params.executableIdentity,
|
|
]),
|
|
);
|
|
}
|
|
|
|
/** Re-resolve a CLI backend's complete executable/package artifact boundary. */
|
|
export async function resolveCliRuntimeArtifactFingerprint(
|
|
params: CliRuntimeArtifactFingerprintParams,
|
|
): Promise<string | undefined> {
|
|
const provider = params.provider.trim();
|
|
const backend = resolveCliBackendConfig(
|
|
provider,
|
|
params.config,
|
|
params.agentId ? { agentId: params.agentId } : {},
|
|
);
|
|
if (!backend) {
|
|
return undefined;
|
|
}
|
|
if (params.runtimeArtifactId && backend.id !== params.runtimeArtifactId) {
|
|
return undefined;
|
|
}
|
|
if (params.executableIdentity && params.executableIdentity.command !== backend.config.command) {
|
|
return undefined;
|
|
}
|
|
const executableIdentity =
|
|
params.executableIdentity ??
|
|
(await resolveCliExecutableIdentity({
|
|
command: backend.config.command,
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
...(params.env ? { env: params.env } : {}),
|
|
...(backend.runtimeArtifact ? { runtimeArtifact: backend.runtimeArtifact } : {}),
|
|
}));
|
|
if (!executableIdentity) {
|
|
return undefined;
|
|
}
|
|
return fingerprintCliRuntimeArtifact({
|
|
provider,
|
|
backendId: backend.id,
|
|
executableIdentity,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve a CLI runtime's non-secret owner shape. The trusted runner emits
|
|
* this projection only after a real successful turn; callers must not treat
|
|
* this pre-run value as proof by itself.
|
|
*/
|
|
export async function resolveCliRuntimeOwnerFingerprint(params: {
|
|
provider: string;
|
|
config: OpenClawConfig;
|
|
agentDir?: string;
|
|
agentId?: string;
|
|
runtimeOwnerId?: string;
|
|
authProfileId?: string;
|
|
skipLocalCredential?: boolean;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
executableIdentity?: CliExecutableIdentity;
|
|
runtimeArtifactFingerprint?: string;
|
|
}): Promise<string | undefined> {
|
|
const provider = params.provider.trim();
|
|
const authProfileId = normalizeOptionalString(params.authProfileId);
|
|
const backend = resolveCliBackendConfig(
|
|
provider,
|
|
params.config,
|
|
params.agentId ? { agentId: params.agentId } : {},
|
|
);
|
|
if (!backend || (params.runtimeOwnerId && backend.id !== params.runtimeOwnerId)) {
|
|
return undefined;
|
|
}
|
|
const runtimeArtifactFingerprint =
|
|
params.runtimeArtifactFingerprint ??
|
|
(await resolveCliRuntimeArtifactFingerprint({
|
|
provider,
|
|
config: params.config,
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
runtimeArtifactId: backend.id,
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
...(params.env ? { env: params.env } : {}),
|
|
...(params.executableIdentity ? { executableIdentity: params.executableIdentity } : {}),
|
|
}));
|
|
if (!runtimeArtifactFingerprint) {
|
|
return undefined;
|
|
}
|
|
let authProfileOwnerFingerprint: string | undefined;
|
|
if (authProfileId) {
|
|
const store = cliAuthEpochDeps.ensureAuthProfileStore(params.agentDir, {
|
|
config: params.config,
|
|
readOnly: true,
|
|
allowKeychainPrompt: false,
|
|
externalCliProviderIds: [provider],
|
|
});
|
|
authProfileOwnerFingerprint = fingerprintAuthProfileOwnerShape({
|
|
profileId: authProfileId,
|
|
credential: store.profiles[authProfileId],
|
|
});
|
|
if (!authProfileOwnerFingerprint) {
|
|
return undefined;
|
|
}
|
|
}
|
|
return fingerprintOpaqueRuntimeOwner({
|
|
kind: "cli-runtime",
|
|
runner: "cli",
|
|
provider,
|
|
backendId: backend.id,
|
|
backendConfig: {
|
|
config: backend.config,
|
|
bundleMcp: backend.bundleMcp,
|
|
bundleMcpMode: backend.bundleMcpMode,
|
|
authEpochMode: backend.authEpochMode,
|
|
nativeToolMode: backend.nativeToolMode,
|
|
sideQuestionToolMode: backend.sideQuestionToolMode,
|
|
},
|
|
...(authProfileId ? { authProfileId } : {}),
|
|
...(authProfileOwnerFingerprint ? { authProfileOwnerFingerprint } : {}),
|
|
...(params.skipLocalCredential ? { skipLocalCredential: true } : {}),
|
|
runtimeArtifactFingerprint,
|
|
});
|
|
}
|