From c12da8a8a2f8a3feec18cacfd43d22e74952f03c Mon Sep 17 00:00:00 2001 From: sallyom Date: Mon, 5 Oct 2026 00:03:00 -0400 Subject: [PATCH 1/5] feat(compute): support custom Codex Responses endpoints Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> --- .../compute/codex-model-configuration.ts | 90 ++++++++++ .../src/drivers/compute/kubernetes/index.ts | 50 +++++- .../compute/kubernetes/runtime-entrypoints.ts | 10 ++ .../src/drivers/compute/plugin-runtime.ts | 30 +++- .../drivers/credential-gateway/openshell.ts | 154 ++++++++++++++---- deploy/runtime/README.md | 6 + docs/flows/harness-execution-topology.md | 30 +++- docs/flows/openshell-sandbox-provisioning.md | 14 ++ .../deploy/openshell-credential-sources.md | 29 +++- docs/reference/credential-sources.md | 6 +- docs/reference/drivers/kubernetes-compute.md | 14 ++ .../drivers/openshell-credential-gateway.md | 44 +++-- docs/reference/harness-execution.md | 26 ++- docs/testing/kubernetes.md | 27 ++- docs/testing/openshell.md | 1 + scripts/k3d | 47 ++++++ tests/conformance/kubernetes-compute.test.mjs | 131 ++++++++++++++- tests/conformance/plugin-compute.test.mjs | 22 +++ tests/helpers/harness-topology-k3d-real.mjs | 54 +++++- .../harness-topology-k3d-real.test.mjs | 2 + tests/integration/k3d-launcher.test.mjs | 80 +++++++++ ...sandbox-driver-openshell-k3d-real.test.mjs | 3 + 22 files changed, 790 insertions(+), 80 deletions(-) create mode 100644 apps/controller/src/drivers/compute/codex-model-configuration.ts diff --git a/apps/controller/src/drivers/compute/codex-model-configuration.ts b/apps/controller/src/drivers/compute/codex-model-configuration.ts new file mode 100644 index 0000000000..baebfefff5 --- /dev/null +++ b/apps/controller/src/drivers/compute/codex-model-configuration.ts @@ -0,0 +1,90 @@ +import type { OpenClawConfigurationDocument } from "@openclaw-enterprise/contracts"; +import { asRecord, splitModelRef } from "@openclaw-enterprise/utils"; + +import type { CodexModelEndpoint } from "./plugin-runtime.ts"; + +/** Compile the admitted selection into stock OpenClaw's explicit native-provider syntax. */ +export function codexGatewayModelConfiguration( + configuration: OpenClawConfigurationDocument, + endpoint: CodexModelEndpoint, +): OpenClawConfigurationDocument { + const document = structuredClone(configuration); + const agents = asRecord(document.agents); + const defaults = asRecord(agents?.defaults); + const entries = Object.values(asRecord(agents?.entries) ?? {}).map(asRecord); + const primary = + typeof defaults?.model === "string" ? defaults.model : asRecord(defaults?.model)?.primary; + const entryPrimary = entries + .map((entry) => + typeof entry?.model === "string" ? entry.model : asRecord(entry?.model)?.primary, + ) + .find((value) => typeof value === "string"); + const selected = typeof primary === "string" ? primary : entryPrimary; + if (typeof selected !== "string") { + throw new Error("Dedicated Codex model selection is missing."); + } + const sourceProvider = splitModelRef(selected).provider; + const references = new Map(); + const selections = new Set(); + const qualify = (reference: string): string => { + const { provider, id } = splitModelRef(reference); + if (provider !== sourceProvider) { + return reference; + } + if (id === undefined) { + throw new Error("Dedicated Codex model reference must include its provider."); + } + references.set(reference, id); + return `codex/${endpoint.modelProvider}/${id}`; + }; + const select = (reference: string): string => { + selections.add(reference); + return qualify(reference); + }; + for (const settings of [defaults, ...entries]) { + if (settings === undefined) { + continue; + } + if (typeof settings.model === "string") { + settings.model = select(settings.model); + } else { + const selection = asRecord(settings.model); + if (typeof selection?.primary === "string") { + selection.primary = select(selection.primary); + } + if (Array.isArray(selection?.fallbacks)) { + selection.fallbacks = selection.fallbacks.map((reference) => select(reference)); + } + } + const policies = asRecord(settings.models); + if (policies !== undefined) { + settings.models = Object.fromEntries( + Object.entries(policies).map(([reference, policy]) => [qualify(reference), policy]), + ); + } + } + const models = asRecord(document.models) ?? {}; + const providers = asRecord(models.providers) ?? {}; + const source = asRecord(providers[sourceProvider]) ?? {}; + const catalog = Array.isArray(source.models) ? source.models.map(asRecord) : []; + // Match each declared ref, not a prefix heuristic: a native ID may itself + // start with "codex/", while a catalog may also contain full OCE refs. + const projected = new Map>(); + for (const [reference, id] of references) { + const row = catalog.find((entry) => entry?.id === reference || entry?.id === id); + // Policy selectors can name wildcards or discovery hints, not catalog rows. + if (row === undefined && !selections.has(reference)) { + continue; + } + projected.set(id, { + ...(row ?? { name: id }), + id: `${endpoint.modelProvider}/${id}`, + }); + } + if (sourceProvider !== "codex") { + delete providers[sourceProvider]; + } + providers.codex = { ...source, models: [...projected.values()] }; + models.providers = providers; + return { ...document, models: models as OpenClawConfigurationDocument }; +} diff --git a/apps/controller/src/drivers/compute/kubernetes/index.ts b/apps/controller/src/drivers/compute/kubernetes/index.ts index 9e52bb6c4b..eb63cc9291 100644 --- a/apps/controller/src/drivers/compute/kubernetes/index.ts +++ b/apps/controller/src/drivers/compute/kubernetes/index.ts @@ -107,6 +107,7 @@ import { } from "../workspace-setup-runtime.ts"; import { ComputeLifecycleDispatcher } from "../lifecycle-hooks.ts"; import { nodeProgramArguments } from "../node-program.ts"; +import { codexGatewayModelConfiguration } from "../codex-model-configuration.ts"; import { discoverHarnessModels } from "../model-discovery.ts"; import { pollHarnessDeviceAuthorization, startHarnessDeviceAuthorization } from "../device-auth.ts"; import { @@ -372,6 +373,7 @@ export interface KubernetesComputeDriverOptions { readonly nodeSelector?: Readonly>; readonly gatewayNodeSelector?: Readonly>; readonly codexSeccompProfile?: string; + readonly codexOpenaiBaseUrl?: string; readonly channels?: { readonly proxyUrl: string; readonly managedProxy?: KubernetesWorkloadPeer & { @@ -1885,8 +1887,12 @@ function requireNativeWorkerSandbox( function gatewayConfigurationDocument( revision: AgentRevision, nativeRuntime: NativeRuntimeSnapshot | undefined, + pluginRuntime?: PluginRuntimeSpec, ): OpenClawConfigurationDocument { if (nativeRuntime === undefined) { + if (pluginRuntime?.kind === "codex" && pluginRuntime.modelEndpoint !== undefined) { + return codexGatewayModelConfiguration(revision.configuration, pluginRuntime.modelEndpoint); + } return revision.configuration; } const cloudWorkers = asRecord(revision.configuration.cloudWorkers) ?? {}; @@ -2058,6 +2064,7 @@ export class KubernetesComputeDriver implements ComputeDriver { nodeSelector: { type: "object", additionalProperties: { type: "string" } }, gatewayNodeSelector: { type: "object", additionalProperties: { type: "string" } }, codexSeccompProfile: { type: "string", minLength: 1 }, + codexOpenaiBaseUrl: { type: "string", minLength: 1 }, channels: { type: "object", required: ["proxyUrl"], @@ -2309,6 +2316,27 @@ export class KubernetesComputeDriver implements ComputeDriver { "Native OpenClaw session capacity must be an integer between 1 and 1024.", ); } + if (options.runtime.codexOpenaiBaseUrl !== undefined) { + let endpoint: URL; + try { + endpoint = new URL(options.runtime.codexOpenaiBaseUrl); + } catch { + throw new ConfigurationFailure("Codex model endpoint must be an HTTPS API URL."); + } + if ( + endpoint.protocol !== "https:" || + endpoint.username !== "" || + endpoint.password !== "" || + endpoint.search !== "" || + endpoint.hash !== "" || + !endpoint.pathname.replace(/\/$/u, "").endsWith("/v1") || + endpoint.pathname.includes("*") + ) { + throw new ConfigurationFailure( + "Codex model endpoint requires HTTPS and a path ending in /v1 without credentials, wildcards, query, or fragment.", + ); + } + } if (options.runtime.codexSeccompProfile !== undefined) { validateCodexSeccompProfile(options.runtime.codexSeccompProfile); } @@ -4008,7 +4036,6 @@ export class KubernetesComputeDriver implements ComputeDriver { await this.prepareRevisionStage("workspace_setup", () => this.deliverWorkspaceSetup(revision, workspaceSetup, namespace), ); - const document = JSON.stringify(gatewayConfigurationDocument(admittedRevision, nativeRuntime)); const configuration = await this.prepareRevisionStage("gateway_configuration", async () => this.gatewayConfiguration( admittedRevision, @@ -4105,10 +4132,14 @@ export class KubernetesComputeDriver implements ComputeDriver { repositoryConsumer, repositoryMaterial, ), + harnessAuth.credentialSource?.config.base_url, ); const hasEnabledPluginSelections = pluginRuntime !== undefined && Object.values(pluginRuntime.runtime.selections).some((selection) => selection.enabled); + const document = JSON.stringify( + gatewayConfigurationDocument(admittedRevision, nativeRuntime, pluginRuntime?.runtime), + ); const pluginStatusContainer = sandboxDriver?.provisionHarness === undefined && pluginRuntime !== undefined && @@ -4865,6 +4896,7 @@ export class KubernetesComputeDriver implements ComputeDriver { repositoryConsumer, repositoryMaterial, ), + harnessAuth.credentialSource?.config.base_url, ); if ( currentRevisionId !== revision.id || @@ -4968,6 +5000,7 @@ export class KubernetesComputeDriver implements ComputeDriver { repositoryConsumer, repositoryMaterial, ), + harnessAuth.credentialSource?.config.base_url, ); const revisionName = `${agentName}-rev-${sha256Hex(revision.id, 12)}`; const configuration = this.gatewayConfiguration( @@ -10065,10 +10098,23 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`; private pluginRuntimeSnapshot( revision: AgentRevision, repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy, + openaiBaseUrl?: string, ): PluginRuntimeSnapshot | undefined { + if ( + openaiBaseUrl === undefined && + revision.harness.id === "codex" && + revision.harness.mode === "dedicated" && + revision.harnessAuth?.method === "api_key" + ) { + openaiBaseUrl = this.options.runtime?.codexOpenaiBaseUrl?.replace(/\/$/u, ""); + } let runtime: PluginRuntimeSpec | undefined; try { - runtime = pluginRuntimeSpecForRevision(revision, repositoryBrokerNetworkPolicy); + runtime = pluginRuntimeSpecForRevision( + revision, + repositoryBrokerNetworkPolicy, + openaiBaseUrl, + ); } catch (error) { throw new ConfigurationFailure( error instanceof Error diff --git a/apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts b/apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts index 917b70aa4e..36695f816c 100644 --- a/apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts +++ b/apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts @@ -3172,6 +3172,16 @@ function probeCodexAuthentication(timeout) { "-c", 'web_search="disabled"', "-c", "project_doc_max_bytes=0", "-c", "check_for_update_on_startup=false", + // The authentication probe ignores user config, so carry the admitted + // compatible endpoint explicitly and use its HTTPS Responses transport. + ...(pluginRuntime?.manifest.modelEndpoint === undefined ? [] : [ + "-c", "model_provider=" + JSON.stringify(pluginRuntime.manifest.modelEndpoint.modelProvider), + "-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + '.name="OpenAI-compatible"', + "-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".base_url=" + JSON.stringify(pluginRuntime.manifest.modelEndpoint.baseUrl), + "-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + '.wire_api="responses"', + "-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".requires_openai_auth=true", + "-c", "model_providers." + pluginRuntime.manifest.modelEndpoint.modelProvider + ".supports_websockets=false", + ]), ...(loginMode === "chatgpt_service_account" ? [ "-c", "forced_chatgpt_workspace_id=" + JSON.stringify(workspaceId), ] : []), diff --git a/apps/controller/src/drivers/compute/plugin-runtime.ts b/apps/controller/src/drivers/compute/plugin-runtime.ts index 05ad93a2d5..b90e9d9beb 100644 --- a/apps/controller/src/drivers/compute/plugin-runtime.ts +++ b/apps/controller/src/drivers/compute/plugin-runtime.ts @@ -37,6 +37,11 @@ export interface CodexRepositoryBrokerNetworkPolicy { readonly domains: Readonly>; } +export interface CodexModelEndpoint { + readonly baseUrl: string; + readonly modelProvider: "openai-compatible"; +} + export type PluginRuntimeSpec = | { readonly kind: "openclaw"; @@ -48,6 +53,7 @@ export type PluginRuntimeSpec = readonly selections: PluginDesiredState; readonly pluginApprovers?: AgentRevision["pluginApprovers"]; readonly repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy; + readonly modelEndpoint?: CodexModelEndpoint; }; function validateDriverMatchesRuntime( @@ -82,6 +88,7 @@ function validateDriverMatchesRuntime( function pluginFreeRuntimeForRevision( revision: Readonly, repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy, + openaiBaseUrl?: string, ): PluginRuntimeSpec | undefined { if (!validPluginApprovers(revision.pluginApprovers)) { throw new Error("AgentRevision plugin approvers are invalid."); @@ -93,6 +100,9 @@ function pluginFreeRuntimeForRevision( ...(revision.pluginApprovers === undefined ? {} : { pluginApprovers: revision.pluginApprovers }), + ...(openaiBaseUrl === undefined + ? {} + : { modelEndpoint: { baseUrl: openaiBaseUrl, modelProvider: "openai-compatible" } }), ...(repositoryBrokerNetworkPolicy === undefined ? {} : { repositoryBrokerNetworkPolicy }), }; } @@ -112,10 +122,11 @@ function pluginFreeRuntimeForRevision( export function pluginRuntimeSpecForRevision( revision: Readonly, repositoryBrokerNetworkPolicy?: CodexRepositoryBrokerNetworkPolicy, + openaiBaseUrl?: string, ): PluginRuntimeSpec | undefined { const state = revision.plugins; if (state === undefined) { - return pluginFreeRuntimeForRevision(revision, repositoryBrokerNetworkPolicy); + return pluginFreeRuntimeForRevision(revision, repositoryBrokerNetworkPolicy, openaiBaseUrl); } if (!validPluginRevisionState(state) || !validPluginApprovers(revision.pluginApprovers)) { throw new Error("AgentRevision plugin selections are invalid."); @@ -132,6 +143,9 @@ export function pluginRuntimeSpecForRevision( kind: "codex", selections: state.plugins, pluginApprovers: revision.pluginApprovers, + ...(openaiBaseUrl === undefined + ? {} + : { modelEndpoint: { baseUrl: openaiBaseUrl, modelProvider: "openai-compatible" } }), ...(repositoryBrokerNetworkPolicy === undefined ? {} : { repositoryBrokerNetworkPolicy }), } : { kind: "openclaw", selections: state.plugins, pluginApprovers: revision.pluginApprovers }; @@ -143,9 +157,14 @@ function codexConfigurationToml(runtime: PluginRuntimeSpec): string | undefined if (runtime.kind !== "codex") { return undefined; } - return Object.keys(runtime.selections).length === 0 - ? CODEX_NO_PLUGIN_CONFIG_TOML - : CODEX_SELECTED_PLUGIN_CONFIG_TOML; + const pluginConfiguration = + Object.keys(runtime.selections).length === 0 + ? CODEX_NO_PLUGIN_CONFIG_TOML + : CODEX_SELECTED_PLUGIN_CONFIG_TOML; + const endpoint = runtime.modelEndpoint; + return endpoint === undefined + ? pluginConfiguration + : `model_provider = ${JSON.stringify(endpoint.modelProvider)}\n\n${pluginConfiguration}\n[model_providers.${endpoint.modelProvider}]\nname = "OpenAI-compatible"\nbase_url = ${JSON.stringify(endpoint.baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = true\nsupports_websockets = false\n`; } export function pluginRuntimeConfigMapData( @@ -177,6 +196,9 @@ function runtimeManifest(runtime: PluginRuntimeSpec): Readonly>; - profile(binaries: readonly string[]): Omit; + profile( + binaries: readonly string[], + baseUrl: string, + ): Omit; } // TODO(credential-gateway next slice): add external and OAuth2 gateway-refresh source types. @@ -58,7 +63,13 @@ const SOURCE_TYPES: readonly OpenShellSourceType[] = Object.freeze([ { catalog: Object.freeze({ type: "openai", - config: Object.freeze([]), + config: Object.freeze([ + Object.freeze({ + name: OPENAI_BASE_URL_CONFIG, + required: false, + description: "HTTPS base URL for the OpenAI-compatible endpoint.", + }), + ]), secrets: Object.freeze([ Object.freeze({ name: "api_key", required: true, description: "OpenAI API key." }), ]), @@ -66,23 +77,39 @@ const SOURCE_TYPES: readonly OpenShellSourceType[] = Object.freeze([ harnessAuth: Object.freeze({ modelProvider: "openai", loginMode: "api_key" }), }), credentials: Object.freeze({ api_key: "OPENAI_API_KEY" }), - profile: (binaries: readonly string[]) => ({ - id: "oce-openai", - displayName: "OpenAI API key (OpenClaw Enterprise)", - category: "PROVIDER_PROFILE_CATEGORY_INFERENCE" as const, - credentials: [ - { - name: "api_key", - envVars: ["OPENAI_API_KEY"], - required: true, - authStyle: "bearer", - headerName: "authorization", - }, - ], - endpoints: [{ host: "api.openai.com", port: 443, protocol: "rest", path: "/v1/**" }], - binaries, - inferenceCapable: true, - }), + profile: (binaries: readonly string[], baseUrl: string) => { + const endpoint = new URL(baseUrl); + return { + id: + baseUrl === OPENAI_DEFAULT_BASE_URL + ? "oce-openai" + : `oce-openai-${sha256Hex(baseUrl, 12)}`, + displayName: + baseUrl === OPENAI_DEFAULT_BASE_URL + ? "OpenAI API key (OpenClaw Enterprise)" + : "OpenAI-compatible API key (OpenClaw Enterprise)", + category: "PROVIDER_PROFILE_CATEGORY_INFERENCE" as const, + credentials: [ + { + name: "api_key", + envVars: ["OPENAI_API_KEY"], + required: true, + authStyle: "bearer", + headerName: "authorization", + }, + ], + endpoints: [ + { + host: endpoint.hostname, + port: Number(endpoint.port || 443), + protocol: "rest", + path: `${endpoint.pathname}/**`, + }, + ], + binaries, + inferenceCapable: true, + }; + }, }, ]); @@ -119,14 +146,50 @@ function sourceType(type: string): OpenShellSourceType { return found; } -function ownedBy(provider: OpenShellProviderResponse, sourceId: string, type: string): boolean { +function ownedBy( + provider: OpenShellProviderResponse, + sourceId: string, + profileId: string, +): boolean { return ( provider.labels[MANAGED_BY_LABEL] === MANAGED_BY && provider.labels[SOURCE_ID_LABEL] === sourceId && - provider.type === sourceType(type).profile([]).id + provider.type === profileId ); } +function sourceBaseUrl(config: Readonly>): string { + const value = config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL; + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new ScopeViolationError("The OpenAI-compatible endpoint must be a valid HTTPS URL."); + } + if ( + endpoint.protocol !== "https:" || + endpoint.hostname.length === 0 || + endpoint.username !== "" || + endpoint.password !== "" || + !endpoint.pathname.replace(/\/$/u, "").endsWith("/v1") || + endpoint.pathname.includes("*") || + endpoint.search !== "" || + endpoint.hash !== "" + ) { + throw new ScopeViolationError( + "The OpenAI-compatible endpoint must use HTTPS and a path ending in /v1 without wildcards, credentials, query, or fragment.", + ); + } + return `${endpoint.origin}${endpoint.pathname.replace(/\/$/u, "")}`; +} + +function profileId( + type: OpenShellSourceType, + source: Pick, +): string { + return type.profile([], sourceBaseUrl(source.config)).id; +} + /** Stores OCC credential sources as OpenShell providers; the supervisor injects them. */ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver { static readonly configurationSchema = Object.freeze({ @@ -180,7 +243,9 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver const type = sourceType(input.type); const workspace = openShellWorkspaceName(context.namespace); const client = this.client(context); - await this.ensureProfile(client, workspace, type, context.signal); + const baseUrl = sourceBaseUrl(context.source.config); + const selectedProfileId = type.profile(this.options.binaries, baseUrl).id; + await this.ensureProfile(client, workspace, type, baseUrl, context.signal); const name = openShellProviderName(context.source.id); const credentials = providerCredentials(type, input); try { @@ -188,7 +253,7 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver { workspace, name, - type: type.profile([]).id, + type: selectedProfileId, labels: { [MANAGED_BY_LABEL]: MANAGED_BY, [SOURCE_ID_LABEL]: context.source.id, @@ -204,7 +269,7 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver } // Replay after an uncertain create adopts only this exact source's provider. const existing = await client.getProvider(workspace, name, context.signal); - if (existing === undefined || !ownedBy(existing, context.source.id, input.type)) { + if (existing === undefined || !ownedBy(existing, context.source.id, selectedProfileId)) { throw new ScopeViolationError( "An OpenShell provider with this source's name is not owned by the source.", ); @@ -229,7 +294,7 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver if (existing === undefined) { return { state: "absent" }; } - if (!ownedBy(existing, context.source.id, input.type)) { + if (!ownedBy(existing, context.source.id, profileId(type, context.source))) { throw new ScopeViolationError( "An OpenShell provider with this source's name is not owned by the source.", ); @@ -257,7 +322,13 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver if (provider === undefined) { return { state: "absent" }; } - if (!ownedBy(provider, context.source.id, context.source.type)) { + if ( + !ownedBy( + provider, + context.source.id, + profileId(sourceType(context.source.type), context.source), + ) + ) { return { state: "failed", reason: "The OpenShell provider is not owned by this source." }; } return { state: "ready" }; @@ -269,7 +340,8 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver const client = this.client(context); const existing = await client.getProvider(workspace, name, context.signal); if (existing !== undefined) { - if (!ownedBy(existing, context.source.id, context.source.type)) { + const selectedProfileId = profileId(sourceType(context.source.type), context.source); + if (!ownedBy(existing, context.source.id, selectedProfileId)) { throw new ScopeViolationError( "An OpenShell provider with this source's name is not owned by the source.", ); @@ -280,16 +352,24 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver } } // Workspace deletion requires no profiles, so the last source of a type removes its profile. - const profileId = sourceType(context.source.type).profile([]).id; + const selectedProfileId = profileId(sourceType(context.source.type), context.source); const remaining = await client.listProviders(workspace, context.signal); - if (!remaining.some((provider) => provider.type === profileId)) { - await client.deleteProviderProfile(workspace, profileId, context.signal); + if (!remaining.some((provider) => provider.type === selectedProfileId)) { + await client.deleteProviderProfile(workspace, selectedProfileId, context.signal); } } async attachForRevision( context: CredentialRevisionContext, ): Promise { + if ( + (context.revision.harness.id !== "codex" && context.revision.harness.id !== "openclaw") || + context.revision.harness.mode !== "dedicated" + ) { + throw new ScopeViolationError( + "OpenShell OpenAI-compatible credential sources require a dedicated Harness.", + ); + } const workspace = openShellWorkspaceName(context.namespace); const client = this.client(context); const attachments: CredentialSourceAttachment[] = []; @@ -297,9 +377,18 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver if (source.driverId !== this.id || source.namespaceId !== context.namespace.id) { throw new ScopeViolationError("The credential source is not owned by this gateway."); } + const selectedProfileId = profileId(sourceType(source.type), source); + const baseUrl = sourceBaseUrl(source.config); + if (context.revision.harness.id === "openclaw" && baseUrl !== OPENAI_DEFAULT_BASE_URL) { + // TODO(dedicated-native-custom-endpoints): replace this default-only check + // with source-bound model endpoint validation when that path is implemented. + throw new ScopeViolationError( + "Dedicated native OpenClaw credential sources require the default OpenAI endpoint.", + ); + } const name = openShellProviderName(source.id); const provider = await client.getProvider(workspace, name, context.signal); - if (provider === undefined || !ownedBy(provider, source.id, source.type)) { + if (provider === undefined || !ownedBy(provider, source.id, selectedProfileId)) { throw new OpenShellCredentialGatewayFailure( "The OpenShell provider for a bound credential source is unavailable.", ); @@ -381,9 +470,10 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver client: OpenShellGatewayClient, workspace: string, type: OpenShellSourceType, + baseUrl: string, signal: AbortSignal, ): Promise { - const base = type.profile(this.options.binaries); + const base = type.profile(this.options.binaries, baseUrl); const digest = sha256Hex(JSON.stringify(base)); const profile: OpenShellProviderProfile = { ...base, diff --git a/deploy/runtime/README.md b/deploy/runtime/README.md index 04ddb97b7a..30bd171543 100644 --- a/deploy/runtime/README.md +++ b/deploy/runtime/README.md @@ -36,6 +36,12 @@ the saved device token for the same Gateway, or still refuses the code, as upstream `node run --pair-if-needed` already does. The source archive and patch hashes identify the resulting custom build. +The Codex adapter preserves namespaced IDs with an explicit native +provider. For a custom endpoint, OCE compiles its private Gateway model references +to include that provider while keeping the admitted Configuration unchanged. +See +[native runtime selection](../../docs/reference/harness-execution.md#native-runtime-selection). + The selected commit does not support dedicated native OpenClaw. That Harness needs required worker placement (`cloudWorkers.requiredProfile`) and native worker inference (`nodeHost.workerRuns.nativeInferenceConfig`), which are not in diff --git a/docs/flows/harness-execution-topology.md b/docs/flows/harness-execution-topology.md index ac28625a37..94407d6136 100644 --- a/docs/flows/harness-execution-topology.md +++ b/docs/flows/harness-execution-topology.md @@ -1,7 +1,7 @@ --- created: 2026-08-21 -updated: 2026-10-03 -last_updated_session: 01a0fe72-58b2-7cc3-b770-7310f5401deb +updated: 2026-10-05 +last_updated_session: authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c --- # Harness Execution Topology Flow @@ -190,6 +190,30 @@ Both PVCs survive this downtime window; a failed candidate is recovered by retry or a new revision. A newer exclusive revision supersedes old reconciliation and maintenance, with no automatic rollback; see [production revision stages](../reference/drivers/compute.md#production-revision-stages). +For dedicated Codex API-key authentication, Kubernetes Compute selects the +bound credential source's endpoint first, then its optional +`runtime.codexOpenaiBaseUrl`. `pluginRuntimeSnapshot` in +`apps/controller/src/drivers/compute/kubernetes/index.ts` passes that endpoint to +`pluginRuntimeConfigMapData` in `apps/controller/src/drivers/compute/plugin-runtime.ts`. +The generated Codex config and manifest select HTTPS Responses streaming for an +explicit compatible endpoint. `probeCodexAuthentication` in +`apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts` carries +that endpoint through CLI overrides because its probe ignores user configuration. +Account login modes and other Harnesses retain their existing endpoints; model +credentials remain exclusively in the Harness Pod. +The runtime manifest carries the endpoint and its native `modelProvider` together. +`gatewayConfigurationDocument` in Kubernetes Compute calls +`apps/controller/src/drivers/compute/codex-model-configuration.ts:codexGatewayModelConfiguration` +only for a dedicated Codex custom endpoint. It clones the admitted document and +qualifies primary/fallback references, selectable policy keys, and catalog IDs +with the endpoint provider in the private Gateway ConfigMap. A native ID beginning +with `codex/` remains part of the ID; full catalog refs are matched against their +declared model selection rather than stripped heuristically. The Harness retains +the original native ID for its probe and receives the same endpoint/provider +tuple in `config.toml`. OpenClaw then separates the explicit provider once +and carries it through thread start, resume, and turn requests. The admitted +Configuration remains unchanged. + Dedicated Codex and dedicated OpenClaw must complete a bounded native authentication/model probe before their Harness becomes ready. While first-deploy [workspace setup](workspace-files.md) is pending, embedded @@ -313,6 +337,8 @@ owns claim sizes, mount paths, StorageClass requirements, and final teardown. ## Changelog +- 2026-10-05 00:06: Add custom Codex Responses endpoint selection and explicit native-provider rendering while preserving admitted model IDs and Harness-only credentials. (authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c - d269c6d03) + - 2026-10-03 16:02: Run configured development API and worker Compute preflight before admitting work. (01a0fe72-58b2-7cc3-b770-7310f5401deb - c04093189f2ba6240f8dc431847c2f487afd11de) - 2026-10-03 15:38: Refuse unsafe split-layout upgrades and converge concurrent legacy password creation. (01a0fe72-58b2-7cc3-b770-7310f5401deb - 94364ae9) diff --git a/docs/flows/openshell-sandbox-provisioning.md b/docs/flows/openshell-sandbox-provisioning.md index 511e42146b..8eb99ab554 100644 --- a/docs/flows/openshell-sandbox-provisioning.md +++ b/docs/flows/openshell-sandbox-provisioning.md @@ -187,6 +187,18 @@ adds only its SHA-256 verifier as `APP_TOKEN_SHA`. It turns the admitted plugin-runtime snapshot into `runtime.json` and `config.toml` workload-file requirements and removes their ConfigMap paths from the remaining environment. +The OpenShell credential gateway derives an endpoint-specific provider profile +from the source's immutable `config.base_url`, defaulting to OpenAI `/v1`. For +custom services, the base path may include a prefix such as `/api/v1`; the profile +binds credentials to that complete base path followed by `/**`. Wildcard base +paths are rejected. Non-Codex consumers retain the default OpenAI endpoint limit; +`attachForRevision` rejects a custom endpoint for those revisions before creating +a Sandbox. For Codex, Compute passes the source endpoint into the +plugin-runtime snapshot. In +`apps/controller/src/drivers/compute/plugin-runtime.ts:codexConfigurationToml`, +a root `model_provider` selection names an OpenAI-compatible provider table +with the endpoint and HTTPS Responses transport. + ### 3. Validate and serialize the Sandbox `apps/controller/src/drivers/sandbox/openshell.ts:provisionHarness` @@ -367,6 +379,8 @@ networking. Native OpenClaw remains a separate verification-only path. - 2026-10-05 16:17: Documented version-fenced workspace-node setup renewal through the revision provider and supervisor refresh. (authoring-run/4f3e6ccd-a967-48c8-9d5d-f29a6d338d7d - fd9a082e2587432bde6282748a82e3025a64fd1a) +- 2026-10-05 00:06: Bind immutable credential-source endpoints to provider profiles and Codex runtime configuration while retaining existing default endpoint limits for other consumers. (authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c - d269c6d03) + - 2026-10-05 12:50: Removed repeated setup and wire-contract detail while preserving the current OpenShell provisioning sequence and moved older entries to the history page. (authoring-run/fd7f6cdb-1d1d-40d5-8d4a-d6d80cd946e7 - 4b5afe0cb653f7dd99fccdb2e3432cbf60e6a03e) - 2026-10-02 16:26: Passed OpenShell's provider-local workspace root to the dedicated Gateway while preserving the canonical Kubernetes Harness root. (authoring-run/3bf937d5-c422-419e-af2d-754abe024ca4 - 987c8c2b4ace1e152262ef6920b6d0f9ff26a086) diff --git a/docs/guides/deploy/openshell-credential-sources.md b/docs/guides/deploy/openshell-credential-sources.md index 326aa13c98..af502caa06 100644 --- a/docs/guides/deploy/openshell-credential-sources.md +++ b/docs/guides/deploy/openshell-credential-sources.md @@ -1,10 +1,11 @@ # Use a credential source on the local OpenShell profile -Use this procedure to register an OpenAI API key with the OpenShell Credential +Use this procedure to register an OpenAI-compatible API key with the OpenShell Credential Gateway and bind it to a dedicated Codex Agent on the [local OpenShell profile](local-kubernetes-development.md#start-the-openshell-fail-closed-profile). -OpenShell keeps its own copy of the key and substitutes it on requests to -`api.openai.com`; the Harness receives only a placeholder. The +OpenShell keeps its own copy of the key and substitutes it only on requests to +the source endpoint; the Harness receives only a placeholder. By default the +endpoint is `https://api.openai.com/v1`. The [credential source reference](../../reference/credential-sources.md) defines the API behavior. @@ -35,8 +36,11 @@ export OCC_NAMESPACE="$(./bin/occ namespace list -o json | jq -r '.[] | select(.name == "default") | .id')" ``` -You need an existing OpenAI API key in a private file, `jq`, and a model name -that key can use. +You need an existing provider API key in a private file, `jq`, and a model name +that key can use. Register the complete native model ID under +`models.providers.codex`, including any namespace. OCE's generated Gateway +configuration supplies the native provider explicitly; do not add it to the +model ID yourself. ## Register the key @@ -52,9 +56,17 @@ rm model-secret.json Register the Secret as an `openai` credential source: +For an OpenAI-compatible service, set `OPENAI_BASE_URL` to its HTTPS `/v1` +endpoint before creating the source. Codex requires Responses API compatibility, +including streaming. The credential source selects Codex's endpoint; keep +`models.providers.codex.baseUrl` at the fail-closed `http://127.0.0.1:9` shown +below. The Gateway must not make direct model requests. +The current source profile uses Bearer authorization; an endpoint requiring +`x-api-key` is not supported by this profile. + ```bash -jq -n --argjson ref "$SECRET_REF" \ - '{name: "openai", type: "openai", secrets: {api_key: $ref}}' > credential-source.json +jq -n --argjson ref "$SECRET_REF" --arg base_url "${OPENAI_BASE_URL:-}" \ + '{name: "openai", type: "openai", config: (if $base_url == "" then {} else {base_url: $base_url} end), secrets: {api_key: $ref}}' > credential-source.json SOURCE_ID="$(./bin/occ credential-source create --file credential-source.json -o json | jq -r .id)" ./bin/occ credential-source get "$SOURCE_ID" @@ -66,7 +78,8 @@ gateway's copy. ## Create the Agent and grant the source -Write a dedicated Codex Configuration. Replace `` in both places: +Write a dedicated Codex Configuration. Replace each `` with the native +model ID, such as `gpt-5.6-luna`: ```bash cat > configuration.json <<'JSON' diff --git a/docs/reference/credential-sources.md b/docs/reference/credential-sources.md index 1fdbe33739..47ce64e17b 100644 --- a/docs/reference/credential-sources.md +++ b/docs/reference/credential-sources.md @@ -44,7 +44,11 @@ The request fields are: - `name`: required; unique within the Namespace. - `type`: required; a type from the gateway catalog. Unknown types fail with `404` before any gateway call. -- `config`: optional nonsecret strings keyed by catalog field name. +- `config`: optional nonsecret strings keyed by catalog field name. For the + OpenShell `openai` type, `base_url` selects an HTTPS OpenAI-compatible `/v1` + endpoint and defaults to `https://api.openai.com/v1`. Codex requires the + OpenAI Responses API. The endpoint is immutable source configuration; register + a new source to change it. - `secrets`: Secret references keyed by catalog field name. Each Secret must belong to the same Namespace: a reference to another Namespace fails with `400 INVALID_REQUEST` before any Secret is read, and a reference to a Secret diff --git a/docs/reference/drivers/kubernetes-compute.md b/docs/reference/drivers/kubernetes-compute.md index ce8077165e..52c62c179e 100644 --- a/docs/reference/drivers/kubernetes-compute.md +++ b/docs/reference/drivers/kubernetes-compute.md @@ -190,6 +190,20 @@ in one AgentRevision Sandbox. Stopping a hosted session releases its slot; saved history does not consume capacity. OpenClaw does not yet retire idle paired-node workers, so size the limit against the Agent workload's CPU and memory limits. +For dedicated Codex Agents using `harnessAuth.method: api_key`, optional +`runtime.codexOpenaiBaseUrl` selects an HTTPS OpenAI-compatible Responses API +endpoint for the Installation. Its path must end in `/v1`; `/api/v1` is supported. +Credentials, wildcards, query strings, and fragments are rejected. When omitted, +Codex uses its default OpenAI endpoint. A bound OpenShell credential source takes +precedence. OAuth, account login modes, and OpenClaw Harnesses do not use this option. +The model key still comes from the Agent's Harness authentication binding and +is projected only into its Codex Pod. Custom endpoints use HTTPS Responses +streaming; the Gateway's native Codex provider remains bound to its authenticated +app server with the fail-closed HTTP endpoint. The generated Gateway configuration +qualifies native model IDs with the endpoint's Codex provider, preserving model +namespaces. The admitted Configuration stays unchanged. +See [Harness execution](../harness-execution.md). + ### Authentication Choose exactly one Kubernetes authentication mode: diff --git a/docs/reference/drivers/openshell-credential-gateway.md b/docs/reference/drivers/openshell-credential-gateway.md index 9412d98fd4..f38a73657c 100644 --- a/docs/reference/drivers/openshell-credential-gateway.md +++ b/docs/reference/drivers/openshell-credential-gateway.md @@ -63,9 +63,16 @@ Sandboxes and reads attachment status. Allow both to reach the gateway. ## Source-type catalog -| Type | Secret fields | Config fields | Rotation | Harness authentication | -| -------- | -------------------- | ------------- | -------- | ---------------------- | -| `openai` | `api_key` (required) | None | `none` | `openai` / `api_key` | +| Type | Secret fields | Config fields | Rotation | Harness authentication | +| -------- | -------------------- | --------------------- | -------- | ---------------------- | +| `openai` | `api_key` (required) | `base_url` (optional) | `none` | `openai` / `api_key` | + +When omitted, `base_url` defaults to `https://api.openai.com/v1`. Set it to an +HTTPS OpenAI-compatible endpoint ending in `/v1` to use a different service. +Path prefixes such as `/api/v1` are supported; wildcard paths are rejected. +Codex requires that endpoint to implement the OpenAI Responses API. The selected +endpoint is part of the source configuration and cannot be changed by updating +its Secret; register a new source to change it. Other OpenShell provider types are not in the catalog, so registration rejects them. @@ -76,12 +83,12 @@ Each OCC Namespace maps to one operator-mode OpenShell Workspace with the same name as its Kubernetes namespace. The Driver manages two objects in that Workspace: -- **Provider profile `oce-openai`.** Registration imports this profile when - missing. It exposes the credential as `OPENAI_API_KEY`, inserts it as a bearer - `authorization` header, and binds it to `api.openai.com:443` with `rest` - protocol and path `/v1/**`, for the configured binaries only. A digest - annotation records the profile content; a configuration change updates the - profile on the next registration. +- **Endpoint-specific provider profile.** Registration imports a profile when + missing. The default endpoint uses `oce-openai`; custom endpoints use a + profile ID derived from the normalized URL. The profile exposes the credential + as `OPENAI_API_KEY`, inserts it as a bearer `authorization` header, and binds + it to that endpoint's host, port, and base path followed by `/**`, for the configured + binaries only. A digest annotation records the profile content. - **One provider per source.** The name is `oce-cs-` followed by 24 hexadecimal characters of the SHA-256 digest of the source ID. Labels record OCC ownership, the source ID, and the Namespace ID. The provider's @@ -102,7 +109,7 @@ the new value only to processes started after the update, so a running Harness keeps the previous value until it restarts. `removeSource` deletes the owned provider and confirms that it is gone. When no -provider of the profile's type remains, it also deletes the profile, because +provider using that endpoint profile remains, it also deletes the profile, because OpenShell cannot delete a Workspace that still holds profiles. For a revision, `attachForRevision` returns each source's provider name. The @@ -117,7 +124,10 @@ process with the provider removed. A Sandbox with no running process, for example one still provisioning or crash-looping, reports `WaitingForProcess`, which stays `pending`. A missing Sandbox reports `absent`. -In the running Sandbox, the Harness environment holds only an +For Codex, Compute selects a named compatible provider with the source endpoint +and HTTPS Responses transport. Other existing Harness consumers retain their +default OpenAI endpoint limit. In the running +Sandbox, the Harness environment holds only an `openshell:resolve:env:` placeholder for `OPENAI_API_KEY`. `codex login --with-api-key` stores that placeholder, and the supervisor proxy substitutes the real key on matching requests. @@ -134,11 +144,11 @@ real key on matching requests. - **Network enforcement.** The guarantee depends on OpenShell's NetworkPolicy, which denies workload-initiated connections. The cluster network plugin must enforce NetworkPolicy. -- **TLS inspection.** The proxy terminates TLS for the profile endpoint, so Codex - must trust the Sandbox CA that OpenShell provides through `SSL_CERT_FILE`. The +- **TLS inspection.** The proxy terminates TLS for the selected profile endpoint, + so Codex must trust the Sandbox CA that OpenShell provides through `SSL_CERT_FILE`. The Codex startup probe keeps `SSL_CERT_FILE` and `SSL_CERT_DIR` in its otherwise minimal environment for this reason. Do not add an uninspected `tls: skip` policy - for `api.openai.com` in the Sandbox's `policy.networkPolicies`; it conflicts + for the selected model endpoint in the Sandbox's `policy.networkPolicies`; it conflicts with the profile. - **Namespaces.** Sources never cross OCC Namespaces. @@ -153,11 +163,11 @@ revision: OpenShell withholds a static key that has no credential binding. A Sandbox policy cannot add a `credential_binding` for a profile that defines endpoints, so changing a Sandbox policy cannot move `OPENAI_API_KEY` off - `api.openai.com`. + the endpoint selected by the source. - **Not covered.** A Platform Admin can create, attach, and exec in Sandboxes in any Workspace. A Workspace admin, which includes OCC's gateway principal, can - update the `oce-openai` profile to add hosts or binaries while Sandboxes use - it, so anyone holding the Backend credential can redirect the key. With + update a profile's endpoints or binaries while Sandboxes use it, so anyone + holding the Backend credential can redirect the key. With `allow_unauthenticated_users` enabled, every caller that reaches the gateway is a Platform Admin; never enable it outside disposable development. The OCC worker can read the source's Kubernetes Secret directly. diff --git a/docs/reference/harness-execution.md b/docs/reference/harness-execution.md index f3a51affa9..3e6b2c7dfa 100644 --- a/docs/reference/harness-execution.md +++ b/docs/reference/harness-execution.md @@ -40,12 +40,24 @@ history. ## Native runtime selection -The selected native model uses a `provider/model` name. Its supported +The selected native model uses a `provider/model` name. The first slash separates +the provider; the complete remaining model ID may contain slashes, as in +`openai/vendor/model`. Provider catalog IDs may use that complete model ID +or the full provider-prefixed name. Its supported `agentRuntime.id` is `openclaw` or `codex`; OCC considers model-specific, Agent-entry, and provider policy, and rejects conflicting explicit policies rather than choosing one. All configured Agent entries must resolve to the same primary model and Harness. +For dedicated Codex with a custom endpoint, OCE compiles the private Gateway +configuration using an explicit native provider. For example, the admitted +`codex/vendor/organization/model` becomes +`codex/openai-compatible/vendor/organization/model` in the generated Gateway +configuration. OpenClaw resolves that to provider `openai-compatible` and +the complete native ID `vendor/organization/model`. Primary and fallback order, +catalog metadata, and runtime policies are preserved. The stored Configuration, +AgentRevision, and Harness probe model remain unchanged. + The resolver selects OpenClaw when there is no model candidate, or when an unambiguous built-in provider without custom provider or plugin routing has no runtime policy. The `openai` and `codex` providers, @@ -143,10 +155,14 @@ a tool event cannot satisfy its success check. The Codex probe runs with a minim environment that keeps only the runtime's TLS trust variables (`SSL_CERT_FILE`, `SSL_CERT_DIR`), so a TLS-inspecting egress proxy can serve it. Dedicated Codex reaches its model over Responses WebSocket by default (embedded OpenClaw uses -HTTP streaming), so an egress proxy or firewall in front of the model host must -allow the WebSocket upgrade. Dedicated Codex retries a confirmed -subprocess timeout once after one second, capping each attempt at 30 seconds -within one 61-second budget that includes the delay. Authentication rejection, malformed +HTTP streaming), so an egress proxy or firewall in front of that model host must +allow the WebSocket upgrade. An explicitly configured compatible endpoint uses +HTTPS Responses streaming, including during the probe that ignores user config. +For Secret-backed dedicated Codex, select the endpoint through the Kubernetes +Compute Driver's [runtime configuration](drivers/kubernetes-compute.md#configuration); +an OpenShell credential source supplies its own endpoint. Probes never log native output. Dedicated Codex retries a confirmed +subprocess timeout once after one second. Each attempt has a 30-second cap within +one 61-second budget, including the delay. Authentication rejection, malformed output, tool events, and external signals without timeout evidence do not retry. Wrappers run under `tini`, so termination during a probe, its delay, or a held failure exits at once without another probe. Exhausted or nonretryable failure diff --git a/docs/testing/kubernetes.md b/docs/testing/kubernetes.md index af1907beb7..8a187ac60f 100644 --- a/docs/testing/kubernetes.md +++ b/docs/testing/kubernetes.md @@ -122,10 +122,25 @@ The helper prepares disposable k3d, isolated PostgreSQL, and gateway/Codex image Start Docker or Podman's API socket (Podman Machine on macOS), then run: ```sh -export OCC_TEST_OPENAI_MODEL=gpt-6-astra -./scripts/k3d +./scripts/k3d --model-id gpt-6-astra ``` +For a custom Codex Responses endpoint, use `--base-url` and the native +`--model-id` on either `test` or the default demo command: + +```sh +./scripts/k3d test --harness codex \ + --base-url https://openrouter.ai/api/v1 --model-id z-ai/glm-5.3-flash +./scripts/k3d --base-url https://openrouter.ai/api/v1 --model-id z-ai/glm-5.3-flash +``` + +Set `OPENAI_API_KEY` privately to the endpoint's key. Flags override +`OCC_TEST_CODEX_OPENAI_BASE_URL` and `OCC_TEST_OPENAI_MODEL`, including prepared +environment records; environment-only use remains supported. Model IDs retain +their full namespace. These flags select the Codex path without OpenShell and +are not accepted by inspection or cleanup commands. After source changes, use +fresh helper state so both images are rebuilt; reusing state retains its images. + When `OPENAI_API_KEY` is unset, the interactive `demo` command prompts for it without echoing the value; `test` requires the variable and `reset` does not. A prompted value exists only in the helper process and its children, never in @@ -289,6 +304,13 @@ recovery. Unsupported images fail. ### Transcript persistence +When `OCC_TEST_CODEX_OPENAI_BASE_URL` selects a custom endpoint, the dedicated +Gateway model-turn case checks Codex's recorded native provider and complete model +ID. The retained-thread case repeats those checks after Gateway replacement, +exercising stock OpenClaw's new-thread, resume, and subsequent-turn resolution. +Use a namespaced `OCC_TEST_OPENAI_MODEL` to protect namespace preservation. A +direct Pod-loopback Codex turn does not exercise that Gateway translation. + Both Harness topologies require SQLite transcripts. Persistence cases query `session_nodes` and `transcript_events`, then verify conversation history and media after gateway Pod replacement. Images with JSONL transcripts cannot exercise @@ -359,6 +381,7 @@ For dedicated Codex coverage, a model override must support Codex custom tools. | `OCC_TEST_DATABASE_URL` | Migrated disposable loopback database named `openclaw_k8s_*`; the ordinary development database fails. | | `OPENAI_API_KEY` | Existing authorized provider credential for real embedded and dedicated model turns. | | `OCC_TEST_OPENAI_MODEL` | Authorized provider model; defaults to `gpt-6-astra`. | +| `OCC_TEST_CODEX_OPENAI_BASE_URL` | Optional HTTPS Responses endpoint for dedicated Codex API-key tests; applied through `runtime.codexOpenaiBaseUrl`. Keep the native Codex provider at its fail-closed loopback URL. | The separate [`harness-topology-k3d-routing-real.test.mjs`](../../tests/integration/harness-topology-k3d-routing-real.test.mjs) diff --git a/docs/testing/openshell.md b/docs/testing/openshell.md index 03d056bc3f..fa776474c3 100644 --- a/docs/testing/openshell.md +++ b/docs/testing/openshell.md @@ -292,6 +292,7 @@ scoped environment file for this suite. | `OPENAI_API_KEY` | Existing authorized provider credential, registered as a credential source for the required real model turn. | | `OCC_TEST_OPENSHELL_HARNESS` | `codex` (default) selects the app-server proof; `openclaw` selects the dedicated native worker without an inbound Harness exposure. | | `OCC_TEST_OPENAI_MODEL` | Authorized provider model; defaults to `gpt-6-astra`. | +| `OCC_TEST_OPENSHELL_MODEL_BASE_URL` | Optional HTTPS `/v1` endpoint for the Codex credential source; requires Responses API compatibility. | | `OCC_TEST_KUBERNETES_KUBECONFIG` | Absolute kubeconfig path for the dedicated disposable k3d cluster. | | `OCC_TEST_KUBERNETES_CONTEXT` | Explicit `k3d-*` context with a verified loopback HTTPS API. | | `OCC_TEST_KUBERNETES_GATEWAY_IMAGE` | Imported immutable real OpenClaw gateway image; `OCC_TEST_KUBERNETES_RUNTIME_IMAGE` is accepted as a fallback. | diff --git a/scripts/k3d b/scripts/k3d index 7e4843a9c0..c9f4d7bcb9 100755 --- a/scripts/k3d +++ b/scripts/k3d @@ -9,6 +9,7 @@ cd "$repo_root" usage() { cat <<'EOF' usage: ./scripts/k3d [demo|test|info|reset|down|help] [--harness codex|openclaw] + [--base-url URL] [--model-id ID] ./scripts/k3d get ./scripts/k3d copy @@ -42,6 +43,13 @@ default; override it with the absolute OCC_K3D_OPENCLAW_SOURCE path. demo prompts for OPENAI_API_KEY when it is not already set. test requires the variable explicitly; reset does not require it. OCC_TEST_OPENAI_MODEL defaults to gpt-4o-mini. + +Codex demo/test model options (including the default demo command): + --base-url URL HTTPS Responses endpoint ending in /v1; overrides + OCC_TEST_CODEX_OPENAI_BASE_URL. + --model-id ID Native model ID, including any namespace; overrides + OCC_TEST_OPENAI_MODEL. Do not add an OCE provider prefix. +Model credentials still come only from OPENAI_API_KEY. EOF } @@ -141,6 +149,8 @@ select_engine() { harness="codex" harness_explicit="false" +base_url="" +model_id="" positional=() positional_count=0 while [ "$#" -gt 0 ]; do @@ -156,6 +166,28 @@ while [ "$#" -gt 0 ]; do harness_explicit="true" shift ;; + --base-url) + [ "$#" -ge 2 ] && [ -n "$2" ] && [[ "$2" != --* ]] || + fail "--base-url requires a nonempty URL" + base_url="$2" + shift 2 + ;; + --base-url=*) + base_url="${1#--base-url=}" + [ -n "$base_url" ] || fail "--base-url requires a nonempty URL" + shift + ;; + --model-id) + [ "$#" -ge 2 ] && [ -n "$2" ] && [[ "$2" != --* ]] || + fail "--model-id requires a nonempty native model ID" + model_id="$2" + shift 2 + ;; + --model-id=*) + model_id="${1#--model-id=}" + [ -n "$model_id" ] || fail "--model-id requires a nonempty native model ID" + shift + ;; *) positional+=("$1") positional_count=$((positional_count + 1)) @@ -213,6 +245,18 @@ case "${1:-demo}" in ;; esac +if [ -n "$base_url$model_id" ]; then + [ "$harness" = codex ] && { [ "$action" = demo ] || [ "$action" = test ]; } || + fail "--base-url and --model-id are supported only for Codex demo and test" +fi + +apply_model_options() { + [ -z "$base_url" ] || export OCC_TEST_CODEX_OPENAI_BASE_URL="$base_url" + # The consumer accepts provider-qualified refs. Add the outer virtual provider + # explicitly so a native ID beginning with codex/ or openai/ remains intact. + [ -z "$model_id" ] || export OCC_TEST_OPENAI_MODEL="codex/$model_id" +} + [ "$action" != demo ] || prompt_openai_api_key for command in node k3d kubectl; do @@ -230,6 +274,7 @@ export OCC_DOCKER_BIN="$container_bin" export OPENCLAW_CI_K3D_BIN="$k3d_bin" export OCC_KUBECTL_BIN="$kubectl_bin" export OCC_TEST_OPENAI_MODEL="${OCC_TEST_OPENAI_MODEL:-gpt-4o-mini}" +apply_model_options # This local workflow always builds the current checkout. Do not let selectors # exported by an earlier test turn its generated localhost references into @@ -304,6 +349,8 @@ load_environment() { fail "prepared environment contains an invalid assignment" export "${environment_record?}" done < "$env_file" + # Prepared environment records must not override explicit invocation options. + apply_model_options } load_state_lane() { diff --git a/tests/conformance/kubernetes-compute.test.mjs b/tests/conformance/kubernetes-compute.test.mjs index f53ddb2ff5..30aa09009a 100644 --- a/tests/conformance/kubernetes-compute.test.mjs +++ b/tests/conformance/kubernetes-compute.test.mjs @@ -885,7 +885,7 @@ test("activation refuses a missing or foreign workspace node before changing the // With `clock` ({ now }), enrollment waits are simulated on that fake clock: an // observation advances it by its whole wait, or to `state.pairAtMs` if the node // pairs within the wait, and never sleeps. -function dedicatedFirstDeployFixture({ statusProxy = true, clock } = {}) { +function dedicatedFirstDeployFixture({ statusProxy = true, clock, modelEndpoint } = {}) { const state = { setupCalls: 0, connected: false, @@ -908,7 +908,11 @@ function dedicatedFirstDeployFixture({ statusProxy = true, clock } = {}) { }; const driver = new KubernetesComputeDriver( routedOptions({ - runtime: { transportSecretPrefix: "transport", gatewayStorageClassName: "local-path" }, + runtime: { + transportSecretPrefix: "transport", + gatewayStorageClassName: "local-path", + ...(modelEndpoint === undefined ? {} : { codexOpenaiBaseUrl: modelEndpoint }), + }, // The API server proxy can reach private status, so activation reads the // node OpenClaw applied. ...(statusProxy ? { network: { pluginStatusProxySourceCidrs: ["192.0.2.20/32"] } } : {}), @@ -1257,6 +1261,98 @@ function dedicatedFirstDeployFixture({ statusProxy = true, clock } = {}) { }; } +test("custom Codex endpoints compile explicit providers without changing the admitted model IDs", async () => { + for (const sourceProvider of ["codex", "openai"]) { + const fixture = dedicatedFirstDeployFixture({ + modelEndpoint: "https://models.example.test/api/v1", + }); + const primary = `${sourceProvider}/vendor/organization/model`; + const fallback = `${sourceProvider}/codex/native`; + const plain = `${sourceProvider}/plain-model`; + const settings = { + model: { primary, fallbacks: [fallback, plain] }, + models: { + [primary]: { agentRuntime: { id: "codex" }, alias: "primary" }, + [fallback]: { agentRuntime: { id: "codex" } }, + [plain]: { agentRuntime: { id: "codex" } }, + [`${sourceProvider}/*`]: { agentRuntime: { id: "codex" } }, + }, + }; + fixture.revision.configuration = admitLoggingConfiguration( + { + ...fixture.revision.configuration, + agents: { + defaults: { + ...structuredClone(settings), + model: sourceProvider === "openai" ? primary : structuredClone(settings.model), + }, + entries: { main: structuredClone(settings) }, + }, + models: { + providers: { + [sourceProvider]: { + agentRuntime: { id: "codex" }, + api: "openai-responses", + baseUrl: "http://127.0.0.1:9", + models: [ + { id: "vendor/organization/model", name: "Primary model", contextWindow: 32000 }, + // Full OCE catalog refs must not strip the native "codex/" namespace. + { id: fallback, name: "Fallback model" }, + { id: "plain-model", name: "Plain model" }, + ], + }, + }, + }, + }, + "info", + ); + const admitted = structuredClone(fixture.revision.configuration); + await fixture.prepare(); + const snapshot = [...fixture.objects.values()].find( + (object) => object.kind === "ConfigMap" && object.data?.["openclaw.json"] !== undefined, + ); + assert.ok(snapshot); + const rendered = JSON.parse(snapshot.data["openclaw.json"]); + const qualified = "codex/openai-compatible/"; + const expectedSelection = { + primary: `${qualified}vendor/organization/model`, + fallbacks: [`${qualified}codex/native`, `${qualified}plain-model`], + }; + assert.deepEqual( + rendered.agents.defaults.model, + sourceProvider === "openai" ? expectedSelection.primary : expectedSelection, + ); + assert.deepEqual(rendered.agents.entries.main.model, expectedSelection); + for (const actual of [rendered.agents.defaults, rendered.agents.entries.main]) { + assert.deepEqual(Object.keys(actual.models), [ + `${qualified}vendor/organization/model`, + `${qualified}codex/native`, + `${qualified}plain-model`, + `${qualified}*`, + ]); + assert.equal(actual.models[`${qualified}vendor/organization/model`].alias, "primary"); + } + assert.deepEqual(rendered.models.providers.codex.models, [ + { + id: "openai-compatible/vendor/organization/model", + name: "Primary model", + contextWindow: 32000, + }, + { id: "openai-compatible/codex/native", name: "Fallback model" }, + { id: "openai-compatible/plain-model", name: "Plain model" }, + ]); + assert.deepEqual(fixture.revision.configuration, admitted); + const runtime = [...fixture.objects.values()].find( + (object) => object.kind === "ConfigMap" && object.data?.["runtime.json"] !== undefined, + ); + assert.ok(runtime); + assert.deepEqual(JSON.parse(runtime.data["runtime.json"]).modelEndpoint, { + baseUrl: "https://models.example.test/api/v1", + modelProvider: "openai-compatible", + }); + } +}); + // The node wiring a Deployment-backed Codex Harness renders from its first start. function harnessNodeSetup(template) { const pod = template.spec; @@ -4004,6 +4100,37 @@ test("the canonical Kubernetes runtime validates channel proxy configuration", ( } }); +test("the canonical Kubernetes runtime validates the dedicated Codex model endpoint", () => { + const runtime = { transportSecretPrefix: "transport", gatewayStorageClassName: "local-path" }; + for (const codexOpenaiBaseUrl of [ + "https://models.example.test/v1", + "https://models.example.test/api/v1/", + ]) { + assert.doesNotThrow(() => + createKubernetesComputeDriver(options({ runtime: { ...runtime, codexOpenaiBaseUrl } })), + ); + } + for (const codexOpenaiBaseUrl of [ + "not-a-url", + "http://models.example.test/v1", + syntheticCredentialUrl({ + username: "user", + password: "password", + host: "models.example.test", + pathname: "/v1", + }), + "https://models.example.test/v2", + "https://models.example.test/*/v1", + "https://models.example.test/v1?key=fixture", + "https://models.example.test/v1#fragment", + ]) { + assert.throws( + () => createKubernetesComputeDriver(options({ runtime: { ...runtime, codexOpenaiBaseUrl } })), + /Codex model endpoint/, + ); + } +}); + test("the canonical Kubernetes runtime validates native OpenClaw session capacity", () => { const runtime = { transportSecretPrefix: "transport", diff --git a/tests/conformance/plugin-compute.test.mjs b/tests/conformance/plugin-compute.test.mjs index 5949f103fb..64692933ca 100644 --- a/tests/conformance/plugin-compute.test.mjs +++ b/tests/conformance/plugin-compute.test.mjs @@ -458,6 +458,28 @@ test("compute renders plugin-free Codex revisions with native default-deny plugi assert.match(data[PLUGIN_RUNTIME_CODEX_CONFIG], /^\[apps\._default\]\nenabled = false/m); }); +test("compute pins the credential source endpoint in dedicated Codex configuration", () => { + const baseUrl = "https://models.example.test/api/v1"; + const runtime = pluginRuntimeSpecForRevision(revision(), undefined, baseUrl); + const data = pluginRuntimeConfigMapData(runtime); + + // Provider selection must remain a root setting; built-in provider IDs are reserved. + assert.match( + data[PLUGIN_RUNTIME_CODEX_CONFIG], + /^model_provider = "openai-compatible"\n\n\[features\]/, + ); + assert.deepEqual(JSON.parse(data[PLUGIN_RUNTIME_MANIFEST]).modelEndpoint, { + baseUrl, + modelProvider: "openai-compatible", + }); + assert.match( + data[PLUGIN_RUNTIME_CODEX_CONFIG], + /\[model_providers\.openai-compatible\][\s\S]*base_url = "https:\/\/models\.example\.test\/api\/v1"[\s\S]*requires_openai_auth = true\nsupports_websockets = false/, + ); + const environment = JSON.parse(pluginRuntimeEnvironment(runtime)[PLUGIN_RUNTIME_ENVIRONMENT]); + assert.equal(environment.codexConfigurationToml, data[PLUGIN_RUNTIME_CODEX_CONFIG]); +}); + test("plugin-free revisions apply explicit Slack approvers for configured Slack and keep unrelated approvals", () => { const rawSlackApprovers = [ { channel: "slack", id: "U456" }, diff --git a/tests/helpers/harness-topology-k3d-real.mjs b/tests/helpers/harness-topology-k3d-real.mjs index 56c8727667..eceb652229 100644 --- a/tests/helpers/harness-topology-k3d-real.mjs +++ b/tests/helpers/harness-topology-k3d-real.mjs @@ -857,6 +857,10 @@ function installationConfiguration(authentication, platformNamespace, slack, opt configuration.drivers.secret.configuration.authentication = authentication; configuration.drivers.configuration.id = "configuration-kubernetes-production"; configuration.drivers.compute.id = "compute-kubernetes-production"; + if (process.env.OCC_TEST_CODEX_OPENAI_BASE_URL !== undefined) { + configuration.drivers.compute.configuration.runtime.codexOpenaiBaseUrl = + process.env.OCC_TEST_CODEX_OPENAI_BASE_URL; + } configuration.drivers.plugin = { id: "codex-plugin", configuration: {} }; // The real Gateway loads its plugins during the first model request. configuration.drivers.compute.configuration.resources.gateway.limits.memory = "2Gi"; @@ -2256,10 +2260,21 @@ async function arrangeProductionTopology(context, mode, slack, options = {}) { assert.ok(gatewayVersion.includes(process.env.OCC_TEST_KUBERNETES_OPENCLAW_VERSION)); } context.diagnostic(`${mode}: ${gatewayVersion}`); + assert.equal( + deployed.data.configuration.agents.defaults.model, + modelReference, + "the admitted revision must retain its original model reference", + ); + const expectedGatewayModel = + mode === "dedicated" && + harnessId === "codex" && + process.env.OCC_TEST_CODEX_OPENAI_BASE_URL !== undefined + ? `codex/openai-compatible/${providerModel}` + : modelReference; await assertGatewayEffectiveDefaultModel( context, { mode, placement, gatewayPlacement, gatewayPod }, - `${harnessId === "codex" ? "codex" : "openai"}/${providerModel}`, + expectedGatewayModel, ); if (legacyTransport !== undefined) { @@ -2442,6 +2457,7 @@ async function assertUnauthorizedCodexSocket(topology) { async function assertActualModelTurn(topology) { topology.gatewayUrl = await topology.refreshGatewayUrl(); + const nonce = `OCC-K3D-${topology.mode.toUpperCase()}-${randomUUID()}`; try { if (topology.mode === "dedicated" && topology.harnessId === "openclaw") { await assertNativeWorkerTurn(topology); @@ -2450,9 +2466,18 @@ async function assertActualModelTurn(topology) { await assertGatewayModelTurn({ gatewayUrl: topology.gatewayUrl, gatewayPassword: topology.gatewayPassword, - nonce: `OCC-K3D-${topology.mode.toUpperCase()}-${randomUUID()}`, + nonce, secrets: [process.env.OPENAI_API_KEY], }); + if (topology.mode === "dedicated" && process.env.OCC_TEST_CODEX_OPENAI_BASE_URL !== undefined) { + // A Gateway turn, unlike a direct app-server call, exercises OpenClaw's + // model resolver. Native records must retain the endpoint provider and full ID. + const rollouts = (await codexRollouts(topology)).filter(({ text }) => text.includes(nonce)); + assert.ok(rollouts.length > 0, "the real Gateway turn must reach the native Codex thread"); + for (const rollout of rollouts) { + assertCustomCodexModelSelection(rollout); + } + } } catch (error) { const projectedSecrets = new Map(); for (const pod of [topology.gatewayPod, topology.harnessPod].filter(Boolean)) { @@ -3230,7 +3255,7 @@ async function assertGatewayEffectiveDefaultModel(context, topology, expectedMod assert.equal( actualModel, expectedModel, - `gateway effective default model changed before live model calls: ${actualModel}`, + `gateway effective default model does not match its runtime projection: ${actualModel}`, ); context.diagnostic(`${topology.mode}: effective default model ${actualModel}`); } @@ -3698,6 +3723,13 @@ async function assertGatewayPodContinuity(context, topology, privateClaim) { rolloutsAfter[0].text.includes(afterNonce), "the resumed Codex thread must record the post-restart turn", ); + if (process.env.OCC_TEST_CODEX_OPENAI_BASE_URL !== undefined) { + // Resume and subsequent turn construction re-resolve the selection; the + // original provider/model tuple must survive both boundaries. + for (const rollout of rolloutsAfter) { + assertCustomCodexModelSelection(rollout); + } + } } context.diagnostic( `Gateway transcript, PNG ${artifactId}, and SQLite integrity survived Pod UID ${previousUid} -> ${topology.gatewayPod.metadata.uid}.`, @@ -3719,6 +3751,15 @@ const gatewayLocalCodexTools = Object.freeze([ "openclaw", ]); +function assertCustomCodexModelSelection(rollout) { + assert.equal(rollout.modelProvider, "openai-compatible"); + assert.ok(rollout.turnModels.length > 0, "the native thread must record its selected turn model"); + assert.ok( + rollout.turnModels.every((model) => model === providerModel), + "native Codex turns must preserve the complete selected model ID without a provider prefix", + ); +} + // Every Codex rollout in the Harness, with its thread ID and the dynamic tools it was given. async function codexRollouts(topology) { const output = await execNode( @@ -3738,11 +3779,14 @@ async function codexRollouts(topology) { walk("/home/node/.codex/sessions"); process.stdout.write(JSON.stringify(files.sort().map((file) => { const text = fs.readFileSync(file, "utf8"); - const meta = JSON.parse(text.split("\\n")[0]).payload; + const records = text.split("\\n").filter(Boolean).map((line) => JSON.parse(line)); + const meta = records[0].payload; const names = (meta.dynamic_tools ?? []).flatMap((tool) => tool.type === "namespace" ? tool.tools.map(({ name }) => name) : [tool.name], ); - return { file, threadId: meta.id, dynamicTools: names, text }; + const turnModels = records.filter((record) => record.type === "turn_context") + .map((record) => record.payload.model); + return { file, threadId: meta.id, modelProvider: meta.model_provider, turnModels, dynamicTools: names, text }; }))); `, ); diff --git a/tests/integration/harness-topology-k3d-real.test.mjs b/tests/integration/harness-topology-k3d-real.test.mjs index d4c90715ee..d6c84eea0e 100644 --- a/tests/integration/harness-topology-k3d-real.test.mjs +++ b/tests/integration/harness-topology-k3d-real.test.mjs @@ -100,6 +100,8 @@ test( ); assert.deepEqual(harnessIdentity.audience, ["openclaw-enterprise"]); const agentService = await resource("service", topology.agentServiceName, topology.placement); + // The Service must select the Agent's ordinary network profile as well as + // its exact revision, so provider-fenced workloads cannot become endpoints. assert.deepEqual(agentService.spec.selector, { "app.kubernetes.io/name": `${topology.agentServiceName}-rev-${hash(topology.revision.id)}`, "openclaw.dev/namespace": topology.agent.namespaceId, diff --git a/tests/integration/k3d-launcher.test.mjs b/tests/integration/k3d-launcher.test.mjs index b76f788731..f4dde7cf34 100644 --- a/tests/integration/k3d-launcher.test.mjs +++ b/tests/integration/k3d-launcher.test.mjs @@ -58,6 +58,10 @@ async function runLauncher( preparedHarness, ambiguousDemo = false, missingBaseImages = false, + inheritedModel = "", + inheritedBaseUrl = "", + preparedModel, + preparedBaseUrl, } = {}, ) { const root = await mkdtemp(join(tmpdir(), `oce-k3d-${engine}-launcher-`)); @@ -167,12 +171,21 @@ if printf '%s' "$*" | grep -q 'scripts/ci/prepare.mjs'; then if [ -n "$OCC_K3D_OPENCLAW_COMMIT" ]; then printf 'OCC_K3D_OPENCLAW_COMMIT=%s\n' "$OCC_K3D_OPENCLAW_COMMIT" >> '${join(state, "env")}' fi + printf '%s\n' '${preparedModel === undefined ? "" : `OCC_TEST_OPENAI_MODEL=${preparedModel}`}' '${preparedBaseUrl === undefined ? "" : `OCC_TEST_CODEX_OPENAI_BASE_URL=${preparedBaseUrl}`}' >> '${join(state, "env")}' exit 0 fi if printf '%s' "$*" | grep -q 'scripts/ci/reset-k3d-model.mjs'; then exit 0; fi if printf '%s' "$*" | grep -q 'scripts/ci/cleanup.mjs'; then rm -f '${join(state, "state.json")}'; exit 0; fi node_args="$*" /usr/bin/env -i PATH=/usr/bin:/bin INVOCATION='${invocation}' NODE_ARGS="$node_args" DOCKER_HOST="\${DOCKER_HOST:-}" OCC_DOCKER_BIN="$OCC_DOCKER_BIN" PODMAN_COMPOSE_PROVIDER="\${PODMAN_COMPOSE_PROVIDER:-}" OCC_K3D_DEMO_STATE="\${OCC_K3D_DEMO_STATE:-}" OCC_K3D_HARNESS="\${OCC_K3D_HARNESS:-}" OPENSHELL_HARNESS="\${OCC_TEST_OPENSHELL_HARNESS:-}" /bin/sh -c 'printf "{\\"args\\":\\"%s\\",\\"dockerHost\\":\\"%s\\",\\"containerBin\\":\\"%s\\",\\"composeProvider\\":\\"%s\\",\\"demoState\\":\\"%s\\",\\"harness\\":\\"%s\\",\\"openShellHarness\\":\\"%s\\"}\\n" "$NODE_ARGS" "$DOCKER_HOST" "$OCC_DOCKER_BIN" "$PODMAN_COMPOSE_PROVIDER" "$OCC_K3D_DEMO_STATE" "$OCC_K3D_HARNESS" "$OPENSHELL_HARNESS" > "$INVOCATION"' +INVOCATION='${invocation}' MODEL="$OCC_TEST_OPENAI_MODEL" BASE_URL="\${OCC_TEST_CODEX_OPENAI_BASE_URL:-}" KEY_PRESENT="$(test -n "$OPENAI_API_KEY" && printf true || printf false)" '${process.execPath}' -e ' + const fs = require("node:fs"); + const record = JSON.parse(fs.readFileSync(process.env.INVOCATION, "utf8")); + record.model = process.env.MODEL; + record.baseUrl = process.env.BASE_URL; + record.apiKeySet = process.env.KEY_PRESENT === "true"; + fs.writeFileSync(process.env.INVOCATION, JSON.stringify(record)); +' `, ); @@ -253,6 +266,9 @@ node_args="$*" ...process.env, DOCKER_HOST: "", OPENAI_API_KEY: apiKey, + OCC_TEST_OPENAI_MODEL: inheritedModel, + OCC_TEST_CODEX_OPENAI_BASE_URL: inheritedBaseUrl, + OCC_K3D_STATE_DIR: "", OCC_TEST_KUBERNETES_GATEWAY_IMAGE: "localhost/stale-gateway@sha256:" + "a".repeat(64), OCC_TEST_KUBERNETES_AGENT_IMAGE: "localhost/stale-agent@sha256:" + "b".repeat(64), OCC_TEST_KUBERNETES_RUNTIME_IMAGE: "localhost/stale-runtime@sha256:" + "c".repeat(64), @@ -336,11 +352,75 @@ node_args="$*" if (missingBaseImages) { return (await readFile(imagePulls, "utf8")).trim().split("\n"); } + return recorded; } test("k3d defaults to the foreground OpenClaw and OCC console demo", (context) => runLauncher(context, "docker")); +test("k3d Codex model flags override inherited and prepared settings for test and default demo", async (context) => { + for (const command of [["test"], []]) { + const recorded = await runLauncher( + context, + "podman", + [ + ...command, + "--base-url", + "https://models.example.test/api/v1", + "--model-id", + "codex/vendor/model", + ], + { + inheritedModel: "inherited-model", + inheritedBaseUrl: "https://inherited.example.test/v1", + preparedModel: "prepared-model", + preparedBaseUrl: "https://prepared.example.test/v1", + }, + ); + // The explicit outer provider protects a native ID whose namespace itself + // is codex/ from the consumer's existing provider-ref normalization. + assert.equal(recorded.model, "codex/codex/vendor/model"); + assert.equal(recorded.baseUrl, "https://models.example.test/api/v1"); + assert.equal(recorded.apiKeySet, true); + assert.doesNotMatch(recorded.args, /test-only-value/); + } +}); + +test("k3d model options retain environment-only use and accept equals syntax", async (context) => { + const inherited = await runLauncher(context, "podman", ["test"], { + inheritedModel: "vendor/model", + inheritedBaseUrl: "https://models.example.test/v1", + }); + assert.equal(inherited.model, "vendor/model"); + assert.equal(inherited.baseUrl, "https://models.example.test/v1"); + const flagged = await runLauncher(context, "podman", [ + "demo", + "--model-id=vendor/model", + "--base-url=https://models.example.test/v1", + ]); + assert.equal(flagged.model, "codex/vendor/model"); + assert.equal(flagged.baseUrl, "https://models.example.test/v1"); +}); + +test("k3d rejects missing model option values and unsupported actions before preparation", async (context) => { + for (const args of [ + ["test", "--base-url"], + ["--model-id="], + ["--model-id", "--base-url", "https://models.example.test/v1"], + ]) { + await assert.rejects(runLauncher(context, "podman", args), /requires a nonempty/); + } + for (const args of [ + ["info", "--model-id", "vendor/model"], + ["test", "--harness", "openclaw", "--base-url", "https://models.example.test/v1"], + ]) { + await assert.rejects( + runLauncher(context, "podman", args), + /supported only for Codex demo and test/, + ); + } +}); + test("k3d demo selects native OpenClaw through OpenShell", (context) => runLauncher(context, "docker", ["demo", "--harness", "openclaw"])); diff --git a/tests/integration/sandbox-driver-openshell-k3d-real.test.mjs b/tests/integration/sandbox-driver-openshell-k3d-real.test.mjs index 2a8815400a..0bcb3800d3 100644 --- a/tests/integration/sandbox-driver-openshell-k3d-real.test.mjs +++ b/tests/integration/sandbox-driver-openshell-k3d-real.test.mjs @@ -56,6 +56,7 @@ const providerModel = (process.env.OCC_TEST_OPENAI_MODEL ?? defaultAgentModel).r /^(?:openai|codex)\//, "", ); +const openShellModelBaseUrl = process.env.OCC_TEST_OPENSHELL_MODEL_BASE_URL?.trim() || undefined; const selected = process.env.OCC_TEST_OPENSHELL_K3D_REAL === "1" || [ @@ -70,6 +71,7 @@ const selected = openShellHelmPath, openShellHelmChart, openShellWorkspaceHelmChart, + openShellModelBaseUrl, ].some(Boolean); const requiresOpenShellK3d = { skip: selected @@ -2096,6 +2098,7 @@ async function prepareProductionInstallation( const modelSource = await request("POST", `/namespaces/${namespaceId}/credential-sources`, { name: `openshell-openai-${randomUUID()}`, type: "openai", + ...(openShellModelBaseUrl === undefined ? {} : { config: { base_url: openShellModelBaseUrl } }), secrets: { api_key: modelSecret.data.ref }, }); assert.equal(modelSource.status, 201, JSON.stringify(modelSource.error)); From 129b9b98812a861cfbdcec386daf5258902ebba8 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:42:48 +0000 Subject: [PATCH 2/5] fix(credentials): validate OpenShell endpoints before persistence Reject invalid source configuration before Secret reads or registration records, preserve remote ownership during invalid-row cleanup, share endpoint normalization, and trim the affected flow docs. Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> --- .../src/drivers/compute/kubernetes/index.ts | 37 ++- .../drivers/credential-gateway/openshell.ts | 64 +++-- .../controller/src/drivers/openai-endpoint.ts | 23 ++ apps/controller/src/http/errors.ts | 6 + docs/flows/credential-source-lifecycle.md | 20 +- docs/flows/harness-execution-topology.md | 111 ++++---- docs/flows/openshell-sandbox-provisioning.md | 105 ++++--- docs/reference/credential-sources.md | 9 +- docs/reference/drivers/credential-gateway.md | 32 ++- docs/reference/drivers/kubernetes-compute.md | 68 ++--- .../drivers/openshell-credential-gateway.md | 10 +- docs/testing/kubernetes.md | 47 ++-- docs/testing/openshell.md | 2 +- packages/contracts/src/index.ts | 2 + packages/occ/src/driver-contract.ts | 1 + packages/occ/src/errors.ts | 11 + packages/occ/src/index.ts | 2 + scripts/ci/test-suites/checks-baseline-1.json | 3 + .../credential-source-occ.test.mjs | 1 + tests/conformance/kubernetes-compute.test.mjs | 2 +- .../credential-source-api.test.mjs | 266 ++++++++++++++++++ .../postgres-worker-agent-revision.test.mjs | 1 + tests/integration/secret-api.test.mjs | 1 + 23 files changed, 565 insertions(+), 259 deletions(-) create mode 100644 apps/controller/src/drivers/openai-endpoint.ts create mode 100644 tests/integration/credential-source-api.test.mjs diff --git a/apps/controller/src/drivers/compute/kubernetes/index.ts b/apps/controller/src/drivers/compute/kubernetes/index.ts index eb63cc9291..7aa845f0cc 100644 --- a/apps/controller/src/drivers/compute/kubernetes/index.ts +++ b/apps/controller/src/drivers/compute/kubernetes/index.ts @@ -1,3 +1,4 @@ +import { normalizeOpenAiBaseUrl } from "../../openai-endpoint.ts"; import { asRecord, isNonEmptyString, @@ -2316,26 +2317,13 @@ export class KubernetesComputeDriver implements ComputeDriver { "Native OpenClaw session capacity must be an integer between 1 and 1024.", ); } - if (options.runtime.codexOpenaiBaseUrl !== undefined) { - let endpoint: URL; - try { - endpoint = new URL(options.runtime.codexOpenaiBaseUrl); - } catch { - throw new ConfigurationFailure("Codex model endpoint must be an HTTPS API URL."); - } - if ( - endpoint.protocol !== "https:" || - endpoint.username !== "" || - endpoint.password !== "" || - endpoint.search !== "" || - endpoint.hash !== "" || - !endpoint.pathname.replace(/\/$/u, "").endsWith("/v1") || - endpoint.pathname.includes("*") - ) { - throw new ConfigurationFailure( - "Codex model endpoint requires HTTPS and a path ending in /v1 without credentials, wildcards, query, or fragment.", - ); - } + if ( + options.runtime.codexOpenaiBaseUrl !== undefined && + normalizeOpenAiBaseUrl(options.runtime.codexOpenaiBaseUrl) === undefined + ) { + throw new ConfigurationFailure( + "Codex model endpoint requires HTTPS and a path ending in /v1 without credentials, wildcards, query, or fragment.", + ); } if (options.runtime.codexSeccompProfile !== undefined) { validateCodexSeccompProfile(options.runtime.codexSeccompProfile); @@ -10106,7 +10094,14 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`; revision.harness.mode === "dedicated" && revision.harnessAuth?.method === "api_key" ) { - openaiBaseUrl = this.options.runtime?.codexOpenaiBaseUrl?.replace(/\/$/u, ""); + openaiBaseUrl = this.options.runtime?.codexOpenaiBaseUrl; + } + if (openaiBaseUrl !== undefined) { + const normalized = normalizeOpenAiBaseUrl(openaiBaseUrl); + if (normalized === undefined) { + throw new ConfigurationFailure("Codex model endpoint requires a valid HTTPS /v1 URL."); + } + openaiBaseUrl = normalized; } let runtime: PluginRuntimeSpec | undefined; try { diff --git a/apps/controller/src/drivers/credential-gateway/openshell.ts b/apps/controller/src/drivers/credential-gateway/openshell.ts index 74448149ec..0cca6f3f01 100644 --- a/apps/controller/src/drivers/credential-gateway/openshell.ts +++ b/apps/controller/src/drivers/credential-gateway/openshell.ts @@ -11,10 +11,12 @@ import type { CredentialSourceStatus, CredentialSourceType, } from "@openclaw-enterprise/contracts"; -import { ScopeViolationError } from "@openclaw-enterprise/occ"; +import { CredentialSourceConfigError, ScopeViolationError } from "@openclaw-enterprise/occ"; import { asRecord, isNonEmptyString, sha256Hex } from "@openclaw-enterprise/utils"; import { isAbsolute } from "node:path"; +import { normalizeOpenAiBaseUrl } from "../openai-endpoint.ts"; + import { openShellProviderName, openShellWorkspaceName, @@ -159,28 +161,13 @@ function ownedBy( } function sourceBaseUrl(config: Readonly>): string { - const value = config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL; - let endpoint: URL; - try { - endpoint = new URL(value); - } catch { - throw new ScopeViolationError("The OpenAI-compatible endpoint must be a valid HTTPS URL."); - } - if ( - endpoint.protocol !== "https:" || - endpoint.hostname.length === 0 || - endpoint.username !== "" || - endpoint.password !== "" || - !endpoint.pathname.replace(/\/$/u, "").endsWith("/v1") || - endpoint.pathname.includes("*") || - endpoint.search !== "" || - endpoint.hash !== "" - ) { + const baseUrl = normalizeOpenAiBaseUrl(config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL); + if (baseUrl === undefined) { throw new ScopeViolationError( "The OpenAI-compatible endpoint must use HTTPS and a path ending in /v1 without wildcards, credentials, query, or fragment.", ); } - return `${endpoint.origin}${endpoint.pathname.replace(/\/$/u, "")}`; + return baseUrl; } function profileId( @@ -236,6 +223,19 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver return SOURCE_TYPES.map((entry) => entry.catalog); } + validateSourceConfig(input: Pick): void { + sourceType(input.type); + if ( + normalizeOpenAiBaseUrl(input.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL) === + undefined + ) { + throw new CredentialSourceConfigError( + OPENAI_BASE_URL_CONFIG, + "The OpenAI-compatible endpoint must use HTTPS and a path ending in /v1 without wildcards, credentials, query, or fragment.", + ); + } + } + async registerSource( context: CredentialSourceContext, input: CredentialSourceInput, @@ -314,6 +314,10 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver } async sourceStatus(context: CredentialSourceContext): Promise { + const type = sourceType(context.source.type); + const baseUrl = normalizeOpenAiBaseUrl( + context.source.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL, + ); const provider = await this.client(context).getProvider( openShellWorkspaceName(context.namespace), openShellProviderName(context.source.id), @@ -323,11 +327,8 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver return { state: "absent" }; } if ( - !ownedBy( - provider, - context.source.id, - profileId(sourceType(context.source.type), context.source), - ) + baseUrl === undefined || + !ownedBy(provider, context.source.id, type.profile([], baseUrl).id) ) { return { state: "failed", reason: "The OpenShell provider is not owned by this source." }; } @@ -335,12 +336,24 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver } async removeSource(context: CredentialSourceContext): Promise { + const type = sourceType(context.source.type); + const baseUrl = normalizeOpenAiBaseUrl( + context.source.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL, + ); const workspace = openShellWorkspaceName(context.namespace); const name = openShellProviderName(context.source.id); const client = this.client(context); const existing = await client.getProvider(workspace, name, context.signal); + // Invalid configuration cannot have created a profile, but a real remote + // collision is not absence. Never guess its owner or delete another profile. + if (baseUrl === undefined) { + if (existing !== undefined) { + throw new ScopeViolationError("The OpenShell provider's ownership cannot be verified."); + } + return; + } + const selectedProfileId = type.profile([], baseUrl).id; if (existing !== undefined) { - const selectedProfileId = profileId(sourceType(context.source.type), context.source); if (!ownedBy(existing, context.source.id, selectedProfileId)) { throw new ScopeViolationError( "An OpenShell provider with this source's name is not owned by the source.", @@ -352,7 +365,6 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver } } // Workspace deletion requires no profiles, so the last source of a type removes its profile. - const selectedProfileId = profileId(sourceType(context.source.type), context.source); const remaining = await client.listProviders(workspace, context.signal); if (!remaining.some((provider) => provider.type === selectedProfileId)) { await client.deleteProviderProfile(workspace, selectedProfileId, context.signal); diff --git a/apps/controller/src/drivers/openai-endpoint.ts b/apps/controller/src/drivers/openai-endpoint.ts new file mode 100644 index 0000000000..4ab79b7039 --- /dev/null +++ b/apps/controller/src/drivers/openai-endpoint.ts @@ -0,0 +1,23 @@ +/** Shared endpoint validation for the Compute and Credential Gateway Drivers. */ +export function normalizeOpenAiBaseUrl(value: string): string | undefined { + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + return undefined; + } + const path = endpoint.pathname.replace(/\/$/u, ""); + if ( + endpoint.protocol !== "https:" || + endpoint.hostname.length === 0 || + endpoint.username !== "" || + endpoint.password !== "" || + !path.endsWith("/v1") || + path.includes("*") || + endpoint.search !== "" || + endpoint.hash !== "" + ) { + return undefined; + } + return endpoint.origin + path; +} diff --git a/apps/controller/src/http/errors.ts b/apps/controller/src/http/errors.ts index cc05fc7dcf..d5cb16c088 100644 --- a/apps/controller/src/http/errors.ts +++ b/apps/controller/src/http/errors.ts @@ -10,6 +10,7 @@ import { ChannelCredentialError, ConfigurationHarnessError, CredentialGatewayNotConfiguredError, + CredentialSourceConfigError, DependencyUnavailableError, DeviceAuthorizationStartError, IAMAccessBindingRoleError, @@ -834,6 +835,11 @@ export function requestFailure(error: unknown): RequestFailure { if (error instanceof IAMRoleInUseError) { return failure(409, "RESOURCE_CONFLICT", error.message); } + if (error instanceof CredentialSourceConfigError) { + return failure(400, "INVALID_REQUEST", error.message, [ + { path: `/config/${error.field}`, code: "INVALID_VALUE" }, + ]); + } if (error instanceof CredentialGatewayNotConfiguredError) { return failure(409, "CREDENTIAL_GATEWAY_NOT_CONFIGURED", error.message); } diff --git a/docs/flows/credential-source-lifecycle.md b/docs/flows/credential-source-lifecycle.md index 95eb9efb5a..f175f0e8d1 100644 --- a/docs/flows/credential-source-lifecycle.md +++ b/docs/flows/credential-source-lifecycle.md @@ -1,7 +1,7 @@ --- created: "2026-09-26" -updated: 2026-10-01 -last_updated_session: authoring-run/b158c89c-3010-42ae-95b4-350b05de7441 +updated: 2026-10-05 +last_updated_session: agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 --- # Credential source lifecycle Flow @@ -74,7 +74,9 @@ Namespace, authorizes `credential_source:create` on it, returns `409 CREDENTIAL_GATEWAY_NOT_CONFIGURED` when the Installation selects no Credential Gateway, and requires a `ready` Namespace. It asks the selected gateway for `listSourceTypes` and rejects an unknown type, an unknown field, or a missing required field with -`ScopeViolationError` (`404`) before any Secret read or gateway write. +`ScopeViolationError` (`404`). It then calls the Driver's side-effect-free +`validateSourceConfig`. Invalid values return `CredentialSourceConfigError` +(`400 INVALID_REQUEST`) before any Secret read, record creation, or gateway write. ### 2. Read Secret values @@ -165,7 +167,10 @@ while an Agent draft, active revision, or pending deployment references it. It moves a `registering` or `ready` record to `deleting`; database triggers prevent leaving `deleting` and returning to `registering`. Outside the transaction, OCC calls `removeSource`. The OpenShell Driver deletes the owned provider, confirms it is gone, and deletes the profile when no -provider of its type remains. A gateway failure returns `503` and leaves the +provider using its endpoint profile remains. For an invalid stored endpoint, +it removes no profiles and succeeds only if the gateway confirms that the provider +is absent. A present provider with unverifiable ownership, an unknown source type, +or a gateway read failure never counts as absent. A gateway failure returns `503` and leaves the record `deleting` for the caller to retry. Until `CREDENTIAL_REGISTRATION_FENCE_MS` (70 seconds) after `createdAt`, OCC keeps the record and returns `503` even after a successful removal: a Driver finishes an @@ -245,6 +250,11 @@ than re-attach the source. refusal and retry, Namespace gating, admission snapshots, and rejection of Secret-backed methods with a gateway selected. It uses an in-process gateway double, not OpenShell. +- `node --test tests/integration/credential-source-api.test.mjs` exercises + Fastify, IAM, OCC, and the OpenShell Driver: invalid endpoint rejection without + residual records, invalid-row deletion recovery, profile isolation, and custom + endpoint lifecycle. Gateway storage and Compute placement are test doubles; + this does not prove OpenShell runtime execution. - `node --test tests/conformance/openshell-gateway-wire.test.mjs` checks the provider, profile, update, and detach RPC encoding against the pinned `v0.1.3-pre.2` wire fixture. @@ -284,6 +294,8 @@ than re-attach the source. ## Changelog +- 2026-10-05 11:36: Validate source configuration before persistence and recover invalid rows without guessing remote ownership. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 9958ef0412565864efba7b13995536d7c2a51d22) + - 2026-10-03 18:00: Registration and update reject a Secret reference to another Namespace as an invalid request instead of not-found, as Secret bindings do. (binding-400b) - 2026-10-03 16:00: Report `withdrawalInProgress` so an exhausted withdrawal no longer reads as in progress; maintenance re-queues only where it is scheduled. (fix-withdrawal-exhausted) - 2026-10-01 20:30: Report a missing Credential Gateway as `409 CREDENTIAL_GATEWAY_NOT_CONFIGURED` at registration. (fix-d93-d100) diff --git a/docs/flows/harness-execution-topology.md b/docs/flows/harness-execution-topology.md index 94407d6136..907385aee8 100644 --- a/docs/flows/harness-execution-topology.md +++ b/docs/flows/harness-execution-topology.md @@ -1,7 +1,7 @@ --- created: 2026-08-21 -updated: 2026-10-05 -last_updated_session: authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c +updated: 2026-10-06 +last_updated_session: agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 --- # Harness Execution Topology Flow @@ -114,46 +114,34 @@ namespaces and refuses a legacy split target without altering its labels or stat The [upgrade requirements](../reference/drivers/kubernetes-compute.md#existing-split-layout-installations) own the operator boundary. -Kubernetes `ensureNamespace` prepares one tenant namespace in a single cluster, -including adopted namespaces; its storage-role label enables discovery. -The two-cluster profile retains its control-cluster Gateway target. -`prepareRevision` and `activateRevision` keep dedicated Gateway and Harness Pods, -identities and PVCs separate in their selected targets. `deliverGatewaySecrets` -validates canonical sources for dedicated Gateways; `deliverHarnessAuth` creates -only the selected model/transport projection. Canonical transport and Gateway -password sources now stay separate across modes. Legacy combined sources remain -for older Pods; Compute copies their password to the separate source before -new templates reference it. If concurrent delivery creates the password source first, -Compute re-reads it and accepts only the exact-owned, identical password; conflicting -or foreign sources still fail. App-server DNS includes the Harness namespace; policies select exact -Namespace, Agent and revision peers. Harness Services select exact Namespace, Agent, revision, workload role, network -profile and Compute-owned workload name. Gateway Services omit revision for a -stable route. These selectors match NetworkPolicy before destination translation. +`ensureNamespace` prepares one single-cluster tenant namespace, including adopted +targets; its storage-role label enables discovery. Two-cluster Gateways retain +their control-cluster target. Dedicated Gateway and Harness Pods, identities, +and PVCs remain separate. `deliverGatewaySecrets` validates canonical sources; +`deliverHarnessAuth` creates selected model/transport projections. +Transport and Gateway passwords use separate sources across modes. Compute +retains legacy combined sources for older Pods and copies their password before +new templates reference the separate source. Concurrent creation adopts only an +exact-owned, identical password; foreign or conflicting sources fail. [Namespaces and isolation](../reference/drivers/kubernetes-compute/networking-and-isolation.md#namespaces-and-isolation) -owns app-server DNS, NetworkPolicy peers and Service selectors. +owns qualified DNS and exact NetworkPolicy/Service selectors. Harness selectors +include revision and network profile; Gateway selectors omit revision for stability. `runtime.gatewayNodeSelector` independently places the Gateway Pod and private-state initializer on trusted nodes. -Because the predecessor Gateway is stopped first (step 3) or otherwise not ready, preparation starts the candidate Gateway after the candidate Harness is -otherwise ready. That candidate Gateway provides the bootstrap endpoint and changes no unrelated -Gateway; the revision remains not ready, and never activates, until its exact workspace node is -enrolled and observed. -A dedicated Codex Harness names its workspace node `agent--workspace` on every -start, so the Gateway's node list keeps one stable name across revisions. - -Dedicated Codex and dedicated OpenClaw keep separate Agent-owned Gateway and -Harness ServiceAccounts. Compute owns the Gateway Pod; the selected SandboxDriver -owns the native Harness Pod. The OpenClaw Harness enrolls as a paired node, owns -its identity and workspace, and alone receives the model key. It reads the -one-use enrollment target from a private file; later starts reuse the persisted -device token. Compute pins the enrolled device in a generated `dedicated-native` -profile with `inference: "worker"`, so a missing or disconnected Harness fails -the turn rather than using Gateway inference. An exact callback route and -session-bound worker admission scope the transport to the owning Agent. -Embedded OpenClaw uses one combined workload with its exact Agent identity -and model key. The worker -has scoped Secret permissions for admitted delivery and node enrollment. Its -trusted workload-writing authority also projects tenant Secrets. Gateway Pods -receive no controller or Harness Kubernetes credentials. +After the Harness is otherwise ready, preparation starts its candidate Gateway +as the bootstrap endpoint; the predecessor is stopped (step 3) or unready. +Activation waits for the exact workspace node to enroll. Dedicated Codex uses +the stable name `agent--workspace` across revisions. + +Dedicated Gateway and Harness ServiceAccounts remain separate. Compute owns the +Gateway Pod; the SandboxDriver owns the native Harness. OpenClaw enrolls from a +private one-use target, then reuses its persisted device token. Only the Harness +receives the model key. Compute pins it in a `dedicated-native` profile with +`inference: "worker"`: disconnection fails turns without Gateway inference. +Exact callbacks and session-bound admission scope transport to the Agent. +Embedded OpenClaw combines the workload, Agent identity, and model key. +The worker's scoped Secret and workload-writing permissions support delivery and +enrollment; Gateways receive no controller or Harness Kubernetes credentials. The selected Sandbox consumes the same rendered projections and explicit login mode in `HarnessWorkloadRequirements`. Unsupported upstream projection fails @@ -176,18 +164,13 @@ for candidate rules and the limits of this observation. `apps/controller/src/worker.ts:ControllerWorker` -For dedicated Kubernetes execution, Compute declares -`requiresStoppedPredecessors`. `ControllerWorker.prepareRevision` stops every -earlier runtime, including its Gateway, and waits for Pod termination before -preparing the replacement, so a redeploy interrupts service until the replacement is ready. -The worker records each stopped predecessor and skips it on later pending passes -and maintenance instead of re-stopping it on each readiness poll. -Compute reports a predecessor that came back (for example, a lost claim's late -write) as an unready successor, not an error, so the worker stops each recorded -predecessor again after one claim lease, then after two, four and so on. A failed -preparation pass, or preparing or activating that predecessor, drops the record. -Both PVCs survive this downtime window; a failed candidate is recovered by retry -or a new revision. A newer exclusive revision supersedes old reconciliation and +Dedicated Compute declares `requiresStoppedPredecessors`: the worker stops all +predecessors, including Gateways, and waits for Pod termination before replacement. +Redeployment interrupts service, but both PVCs survive. Retry or a new revision +recovers a failed candidate. Recorded stops suppress repeated readiness/maintenance +calls. A returning predecessor makes its successor unready; the worker retries +its stop after one claim lease, then two, four, and so on. Failed preparation or +preparing/activating that predecessor clears its stop record. A newer exclusive revision supersedes old reconciliation and maintenance, with no automatic rollback; see [production revision stages](../reference/drivers/compute.md#production-revision-stages). For dedicated Codex API-key authentication, Kubernetes Compute selects the @@ -201,18 +184,16 @@ explicit compatible endpoint. `probeCodexAuthentication` in that endpoint through CLI overrides because its probe ignores user configuration. Account login modes and other Harnesses retain their existing endpoints; model credentials remain exclusively in the Harness Pod. -The runtime manifest carries the endpoint and its native `modelProvider` together. -`gatewayConfigurationDocument` in Kubernetes Compute calls +Compute normalizes both endpoint sources with +`apps/controller/src/drivers/openai-endpoint.ts:normalizeOpenAiBaseUrl`. +For custom endpoints, `gatewayConfigurationDocument` calls `apps/controller/src/drivers/compute/codex-model-configuration.ts:codexGatewayModelConfiguration` -only for a dedicated Codex custom endpoint. It clones the admitted document and -qualifies primary/fallback references, selectable policy keys, and catalog IDs -with the endpoint provider in the private Gateway ConfigMap. A native ID beginning -with `codex/` remains part of the ID; full catalog refs are matched against their -declared model selection rather than stripped heuristically. The Harness retains -the original native ID for its probe and receives the same endpoint/provider -tuple in `config.toml`. OpenClaw then separates the explicit provider once -and carries it through thread start, resume, and turn requests. The admitted -Configuration remains unchanged. +to clone the admitted document and qualify model references, policy keys, and +catalog IDs in the private Gateway ConfigMap. Full refs match declared selections; +native IDs, including a `codex/` prefix, remain intact. The manifest and +`config.toml` carry the endpoint/provider pair. OpenClaw separates the explicit +provider once for thread start, resume, and turns; the Harness probe keeps the +native ID. The admitted Configuration is unchanged. Dedicated Codex and dedicated OpenClaw must complete a bounded native authentication/model probe before their Harness becomes ready. @@ -303,8 +284,6 @@ owns claim sizes, mount paths, StorageClass requirements, and final teardown. `pnpm test:files --test-name-pattern='dedicated replacement starts a candidate Gateway' -- tests/conformance/kubernetes-compute.test.mjs`. - Check embedded Gateway repair after a never-served unready predecessor: `pnpm test:files --test-name-pattern='never-served unready Gateway' -- tests/conformance/kubernetes-compute.test.mjs`. -- Run real disposable-k3d Kubernetes coverage for both production topologies, exact identity and - model-key placement, authenticated dedicated transport, isolated networking, and active routing. - Run `node --test tests/integration/docker-compute-real.test.mjs` for real Docker Compose embedded and dedicated model turns, or `node --test tests/integration/harness-topology-k3d-real.test.mjs` for real Kubernetes @@ -337,6 +316,10 @@ owns claim sizes, mount paths, StorageClass requirements, and final teardown. ## Changelog +- 2026-10-06 11:17: Reconciled endpoint flow with current namespace and provider provisioning; condensed repeated prose. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - b6dc6b87a461ad33374e133d15cc739d8e074fea) + +- 2026-10-05 11:36: Normalize runtime endpoints with the shared Driver validator; trim repeated topology narration. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 9958ef0412565864efba7b13995536d7c2a51d22) + - 2026-10-05 00:06: Add custom Codex Responses endpoint selection and explicit native-provider rendering while preserving admitted model IDs and Harness-only credentials. (authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c - d269c6d03) - 2026-10-03 16:02: Run configured development API and worker Compute preflight before admitting work. (01a0fe72-58b2-7cc3-b770-7310f5401deb - c04093189f2ba6240f8dc431847c2f487afd11de) diff --git a/docs/flows/openshell-sandbox-provisioning.md b/docs/flows/openshell-sandbox-provisioning.md index 8eb99ab554..a5f1c0aa46 100644 --- a/docs/flows/openshell-sandbox-provisioning.md +++ b/docs/flows/openshell-sandbox-provisioning.md @@ -1,31 +1,26 @@ --- created: "2026-09-21" -updated: 2026-10-05 -last_updated_session: authoring-run/4f3e6ccd-a967-48c8-9d5d-f29a6d338d7d +updated: 2026-10-06 +last_updated_session: agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 --- # OpenShell Sandbox provisioning flow ## Overview -The Kubernetes Compute Driver delegates dedicated Codex and native OpenClaw -Harnesses to the selected OpenShell Sandbox Driver. One deployment-paired OpenShell Gateway uses -an explicitly configured workspace mode. Operator mode is implemented: for each -OCC Namespace, the Driver labels the Kubernetes namespace, reconciles rendered -workspace-chart resources, and creates or adopts an OpenShell Workspace with -the same physical name. Managed mode is recognized but fails before mutation. -Sandbox requests are homed in the operator-mode Workspace. - -The model credential uses a [credential source](credential-source-lifecycle.md), -so the Agent receives no Secret permission. For dedicated Codex, Compute passes -only the app-server token verifier and admitted runtime-file contents. The -Sandbox Driver realizes the files as a revision-owned OpenShell provider and -enables bearer passthrough on the Codex service exposure. Codex, not OpenShell, -authenticates the Gateway request. Compute detects the Driver's endpoint -capability and configures the Agent Gateway to use the OpenShell-advertised -WebSocket origin. It does not activate the direct Kubernetes Harness Service for -that path. Drivers without the capability retain the existing Service or private -route behavior. +Kubernetes Compute delegates dedicated Codex and native OpenClaw Harnesses to +the selected OpenShell Sandbox Driver and deployment-paired Gateway. Operator +mode labels the Kubernetes namespace, reconciles workspace-chart resources, and +creates or adopts a same-named OpenShell Workspace. Sandbox requests use that +Workspace. Managed mode fails before mutation. + +The [credential source](credential-source-lifecycle.md) needs no Agent Secret +permission. Compute supplies dedicated Codex's app-server verifier and admitted +runtime files; the SandboxDriver stores them in a revision-owned provider. +Codex authenticates Gateway requests through bearer-passthrough exposure. +Compute selects the advertised WebSocket origin, leaving the direct Harness +Service inactive. Drivers without this endpoint capability retain Service or +private-route behavior. The development Driver puts the expiring workspace-node setup envelope in a revision-owned provider file. The Harness signs the bootstrap token that the @@ -33,10 +28,8 @@ Gateway receives, while network policy limits its use to the node executable and Agent Gateway endpoint. This exposes the token to the Sandbox and is not a production credential-delivery contract. -The local Kubernetes development profile installs the pinned Gateway and -renders the workspace chart into the Installation configuration, in either a -Kubernetes-only or Compose control plane. Neither uses the verification-only -compatibility projection. +Both Kubernetes-only and Compose development control planes install the pinned +Gateway and render the workspace chart, without compatibility projections. ## Entry Points @@ -164,16 +157,13 @@ so the same name satisfies OpenShell v0.1.3-pre.2's 19-character limit. `apps/controller/src/drivers/compute/kubernetes/index.ts:prepareRevision` -For a dedicated revision with `provisionHarness`, Compute derives Harness image, -command, labels, environment, workspace mounts, optional workload identity, and -resources from the same Deployment shape used by the regular Kubernetes path. -For a first revision, Compute starts the dedicated Agent Gateway with its -existing fail-closed transport target while the direct Agent Service remains -inactive. It returns an incomplete readiness result until that Gateway is ready -and its Agent-owned workspace-node setup Secret exists. No credential attachment, -runtime provider, or Sandbox creation occurs during that wait. Compute then adds -the setup Secret reference to the derived workload before invoking the Sandbox -Driver. +With `provisionHarness`, Compute derives image, command, labels, environment, +mounts, optional identity, and resources from its ordinary Deployment shape. +First-deploy preparation starts the Gateway on fail-closed transport, leaving +the Agent Service inactive. Until Gateway readiness and its Agent-owned node +setup Secret exist, preparation stays incomplete without attaching credentials +or creating providers/Sandboxes. Compute then adds that Secret reference and +invokes the SandboxDriver. The local OpenShell profile disables projected workload identity. If it is enabled, OpenShell rejects the revision before Gateway mutation because the pinned API cannot preserve the exact Agent ServiceAccount and token. @@ -187,17 +177,14 @@ adds only its SHA-256 verifier as `APP_TOKEN_SHA`. It turns the admitted plugin-runtime snapshot into `runtime.json` and `config.toml` workload-file requirements and removes their ConfigMap paths from the remaining environment. -The OpenShell credential gateway derives an endpoint-specific provider profile -from the source's immutable `config.base_url`, defaulting to OpenAI `/v1`. For -custom services, the base path may include a prefix such as `/api/v1`; the profile -binds credentials to that complete base path followed by `/**`. Wildcard base -paths are rejected. Non-Codex consumers retain the default OpenAI endpoint limit; -`attachForRevision` rejects a custom endpoint for those revisions before creating -a Sandbox. For Codex, Compute passes the source endpoint into the -plugin-runtime snapshot. In -`apps/controller/src/drivers/compute/plugin-runtime.ts:codexConfigurationToml`, -a root `model_provider` selection names an OpenAI-compatible provider table -with the endpoint and HTTPS Responses transport. +The credential gateway derives the profile from immutable `config.base_url`, +defaulting to OpenAI `/v1`. Its shared validator rejects invalid endpoints before +[registration](credential-source-lifecycle.md#1-admit-the-registration-request). +The profile binds credentials to the full base path, including prefixes such as +`/api/v1`, followed by `/**`. `attachForRevision` rejects custom endpoints for +native OpenClaw. For Codex, +`apps/controller/src/drivers/compute/plugin-runtime.ts:codexConfigurationToml` +selects a named provider with the normalized endpoint and HTTPS Responses transport. ### 3. Validate and serialize the Sandbox @@ -283,10 +270,12 @@ For Codex, `GetService` must also return the unnamed bearer-passthrough endpoint on the admitted port. Workspace `sandbox:write` is the trust boundary because a holder can replace the Sandbox. -The Backend shares one client per endpoint but does not cache failed setup. -Cancellation is checked after setup and before dispatch; after dispatch it -cancels the local gRPC call without proving the remote mutation stopped. The -lifecycle therefore recovers uncertain effects through adoption and cleanup. +The Backend shares one client per endpoint and retries failed setup. Unary calls +check cancellation after setup and credential preparation, before dispatch. +Setup cancellation waits for the pending step; it cannot bound stalled +initialization or file reads. After dispatch, cancellation rejects the caller and +cancels local gRPC, not necessarily the remote mutation. The lifecycle must +recover uncertain effects. `GetService` retains two forms of the URL. The normalized form preserves the existing control-endpoint behavior used by local clients. The advertised form @@ -344,14 +333,12 @@ Sandbox is an idempotent success. Codex cleanup then verifies and deletes the revision provider. Namespace cleanup removes the shared profile after its runtime providers are gone. -The current unified `cleanup` contract receives the immutable revision during -revision shutdown and no revision during Namespace deletion. Namespace deletion -runs it after revision resources are gone. OpenShell verifies exact Workspace -ownership, deletes remaining owned runtime providers and the shared profile, -sends idempotent `DeleteWorkspace`, and then removes configured workspace-chart -resources and NetworkPolicies in reverse order. A terminating Workspace remains -eligible for retry after a lost response. Only after Sandbox cleanup succeeds -does Kubernetes Compute delete the Kubernetes namespace. +Namespace deletion calls `cleanup` without a revision after revision resources +are gone. OpenShell verifies Workspace ownership, deletes remaining owned runtime +providers and the shared profile, sends idempotent `DeleteWorkspace`, then removes +workspace-chart resources and NetworkPolicies in reverse order. A terminating +Workspace remains retryable after a lost response. Compute deletes the Kubernetes +namespace only after this cleanup succeeds. ## Debugging and verification @@ -377,8 +364,12 @@ networking. Native OpenClaw remains a separate verification-only path. ## Changelog +- 2026-10-06 11:17: Reconciled endpoint flow with current namespace and provider provisioning; condensed repeated prose. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - b6dc6b87a461ad33374e133d15cc739d8e074fea) + - 2026-10-05 16:17: Documented version-fenced workspace-node setup renewal through the revision provider and supervisor refresh. (authoring-run/4f3e6ccd-a967-48c8-9d5d-f29a6d338d7d - fd9a082e2587432bde6282748a82e3025a64fd1a) +- 2026-10-05 11:36: Trace pre-registration endpoint validation and condense repeated setup and runtime details. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 9958ef0412565864efba7b13995536d7c2a51d22) + - 2026-10-05 00:06: Bind immutable credential-source endpoints to provider profiles and Codex runtime configuration while retaining existing default endpoint limits for other consumers. (authoring-run/4e4824a1-f107-44c2-90bf-00fe13ff650c - d269c6d03) - 2026-10-05 12:50: Removed repeated setup and wire-contract detail while preserving the current OpenShell provisioning sequence and moved older entries to the history page. (authoring-run/fd7f6cdb-1d1d-40d5-8d4a-d6d80cd946e7 - 4b5afe0cb653f7dd99fccdb2e3432cbf60e6a03e) diff --git a/docs/reference/credential-sources.md b/docs/reference/credential-sources.md index 47ce64e17b..26cc168fa1 100644 --- a/docs/reference/credential-sources.md +++ b/docs/reference/credential-sources.md @@ -48,14 +48,15 @@ The request fields are: OpenShell `openai` type, `base_url` selects an HTTPS OpenAI-compatible `/v1` endpoint and defaults to `https://api.openai.com/v1`. Codex requires the OpenAI Responses API. The endpoint is immutable source configuration; register - a new source to change it. + a new source to change it. Invalid endpoint values return `400 INVALID_REQUEST` + before OCC reads Secrets or stores a source record. - `secrets`: Secret references keyed by catalog field name. Each Secret must belong to the same Namespace: a reference to another Namespace fails with `400 INVALID_REQUEST` before any Secret is read, and a reference to a Secret the Namespace does not hold fails with `404`. -OCC rejects unknown fields and missing required fields before it reads any -Secret. It reads each value through the Secret Driver, sends the values to the +OCC rejects unknown fields, missing required fields, and Driver-invalid configuration +before it reads any Secret. It reads each value through the Secret Driver, sends the values to the gateway, and stores only the Secret references. OCC records the source as `registering` before the gateway call. If the gateway rejects the registration, OCC deletes any copy and the record. If the call fails without an answer, such as @@ -194,7 +195,7 @@ deleted, and its referenced Secrets cannot be deleted. | Status | Meaning | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `400 INVALID_REQUEST` | The body or a field name is malformed, or a Secret reference names another Namespace. | +| `400 INVALID_REQUEST` | The body, field name, or configuration value is invalid, or a Secret reference names another Namespace. | | `403 FORBIDDEN` | A required `credential_source` or `secret` permission is missing. | | `404 NOT_FOUND` | The source, Secret, or type is not in the exact Namespace or catalog, or a catalog field is invalid; or the Agent's active revision does not use the source or has no withdrawal for it. | | `409 NAMESPACE_NOT_READY` | The Namespace is not `ready`. | diff --git a/docs/reference/drivers/credential-gateway.md b/docs/reference/drivers/credential-gateway.md index 7c0901d01b..f349d838b6 100644 --- a/docs/reference/drivers/credential-gateway.md +++ b/docs/reference/drivers/credential-gateway.md @@ -28,17 +28,24 @@ there is no fallback to environment delivery. The [shared interface](../../../packages/contracts/src/index.ts) requires every method below. Startup rejects a Driver that omits one. -| Operation | Inputs and preconditions | Result or side effects | Failure or absence | -| ------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `listSourceTypes` | Cancellation signal. | The implementation's catalog of `CredentialSourceType` entries. | OCC treats a failure as dependency unavailable. | -| `registerSource` | Ready Namespace, the new source record, and resolved Secret values. | Stores the value in the gateway and returns `ready`, `pending`, `failed`, or `absent`. | `failed` or `absent` fails registration; OCC then calls `removeSource`. | -| `updateSource` | Existing source and new resolved values. | Replaces the stored values for processes started afterwards and returns the source state. | `failed` or `absent` fails the update; OCC keeps the Secret references unchanged. | -| `rotateSource` | Existing source. | Rotates gateway-refreshed credentials. | No OCC caller yet; no delivered source type uses gateway refresh. | -| `sourceStatus` | Existing source. | Live source state and an optional safe reason. | OCC reports `failed` with a fixed reason when the call throws. | -| `removeSource` | Source record. | Deletes the stored copy. An already-absent source counts as removed. | A failure leaves the OCC record `deleting` for retry. | -| `attachForRevision` | Namespace, immutable revision, and the bound source records. | Exactly one `{ sourceId, ref }` attachment per bound source. `ref` is opaque to OCC. | Throws when a source is unavailable or foreign; the revision does not provision. | -| `attachmentStatus` | The same context plus the provisioned `SandboxResourceRef`. | Per-source state: `ready`, `pending`, `withheld`, `failed`, `revoked`, or `absent`. | Compute blocks activation on any state other than `ready` or `pending`. | -| `withdraw` | Placement, revision, its required `SandboxResourceRef`, and one source ID; no source list. | Revokes that revision's access, including running processes. `revoked` only on gateway evidence, `absent` when the Sandbox is gone, otherwise `pending`. | Anything but `revoked` or `absent` keeps the OCC withdrawal `pending` for retry. | +| Operation | Inputs and preconditions | Result or side effects | Failure or absence | +| ---------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `listSourceTypes` | Cancellation signal. | The implementation's catalog of `CredentialSourceType` entries. | OCC treats a failure as dependency unavailable. | +| `validateSourceConfig` | Catalog-checked type and nonsecret configuration. | Synchronous validation without I/O, Secret reads, or persistence. | Invalid values throw `CredentialSourceConfigError` (`400`). | +| `registerSource` | Ready Namespace, the new source record, and resolved Secret values. | Stores the value in the gateway and returns `ready`, `pending`, `failed`, or `absent`. | `failed` or `absent` fails registration; OCC then calls `removeSource`. | +| `updateSource` | Existing source and new resolved values. | Replaces the stored values for processes started afterwards and returns the source state. | `failed` or `absent` fails the update; OCC keeps the Secret references unchanged. | +| `rotateSource` | Existing source. | Rotates gateway-refreshed credentials. | No OCC caller yet; no delivered source type uses gateway refresh. | +| `sourceStatus` | Existing source. | Live source state and an optional safe reason. | OCC reports `failed` with a fixed reason when the call throws. | +| `removeSource` | Source record. | Deletes the stored copy. An already-absent source counts as removed. | A failure leaves the OCC record `deleting` for retry. | +| `attachForRevision` | Namespace, immutable revision, and the bound source records. | Exactly one `{ sourceId, ref }` attachment per bound source. `ref` is opaque to OCC. | Throws when a source is unavailable or foreign; the revision does not provision. | +| `attachmentStatus` | The same context plus the provisioned `SandboxResourceRef`. | Per-source state: `ready`, `pending`, `withheld`, `failed`, `revoked`, or `absent`. | Compute blocks activation on any state other than `ready` or `pending`. | +| `withdraw` | Placement, revision, its required `SandboxResourceRef`, and one source ID; no source list. | Revokes that revision's access, including running processes. `revoked` only on gateway evidence, `absent` when the Sandbox is gone, otherwise `pending`. | Anything but `revoked` or `absent` keeps the OCC withdrawal `pending` for retry. | + +`validateSourceConfig({ type, config })` checks nonsecret values synchronously, +without network calls or other effects. OCC calls it after catalog field checks, +before reading Secrets or creating a record. Invalid values throw +`CredentialSourceConfigError` with a safe field name and message; HTTP reports +`400 INVALID_REQUEST`. Do not include submitted values in the error. A `CredentialSourceType` declares: @@ -86,7 +93,8 @@ configuration. The interface has no initializer or destructor. 1. **Registration.** The API validates the request against `listSourceTypes`: unknown types, unknown fields, and missing required fields fail before any - gateway call. Compute's `resolveSandboxNamespace` supplies the Namespace's + gateway write. The Driver validates configuration values with + `validateSourceConfig` before any Secret read or persistence. Compute's `resolveSandboxNamespace` supplies the Namespace's runtime placement, which the gateway shares with the paired Sandbox. The API reads each Secret value and commits the record as `registering`, then calls `registerSource` outside the transaction. A second transaction moves the diff --git a/docs/reference/drivers/kubernetes-compute.md b/docs/reference/drivers/kubernetes-compute.md index 52c62c179e..e85b56770d 100644 --- a/docs/reference/drivers/kubernetes-compute.md +++ b/docs/reference/drivers/kubernetes-compute.md @@ -129,9 +129,8 @@ generation. ## Configuration -Select the Kubernetes Compute Driver in the Installation startup YAML. Set -`OCC_CONFIG_PATH` to that file's absolute path for both the controller API and -worker. +Select Kubernetes Compute in Installation YAML; set both API and worker +`OCC_CONFIG_PATH` to its absolute path. ```yaml drivers: @@ -180,9 +179,8 @@ drivers: codexSeccompProfile: profiles/codex-0.160.0.json ``` -This is only the Compute Driver portion of the Installation configuration; the -[complete production Installation example](../../guides/deploy/production-installation.md#configure-the-installation) -adds the other required Drivers and settings. +This example configures only Compute. The [complete Installation example](../../guides/deploy/production-installation.md#configure-the-installation) +supplies remaining required Drivers and settings. `runtime.nativeOpenClawSessionCapacity` accepts an integer from `1` through `1024` and defaults to `8`. It bounds retained native OpenClaw session workers @@ -190,19 +188,14 @@ in one AgentRevision Sandbox. Stopping a hosted session releases its slot; saved history does not consume capacity. OpenClaw does not yet retire idle paired-node workers, so size the limit against the Agent workload's CPU and memory limits. -For dedicated Codex Agents using `harnessAuth.method: api_key`, optional -`runtime.codexOpenaiBaseUrl` selects an HTTPS OpenAI-compatible Responses API -endpoint for the Installation. Its path must end in `/v1`; `/api/v1` is supported. -Credentials, wildcards, query strings, and fragments are rejected. When omitted, -Codex uses its default OpenAI endpoint. A bound OpenShell credential source takes -precedence. OAuth, account login modes, and OpenClaw Harnesses do not use this option. -The model key still comes from the Agent's Harness authentication binding and -is projected only into its Codex Pod. Custom endpoints use HTTPS Responses -streaming; the Gateway's native Codex provider remains bound to its authenticated -app server with the fail-closed HTTP endpoint. The generated Gateway configuration -qualifies native model IDs with the endpoint's Codex provider, preserving model -namespaces. The admitted Configuration stays unchanged. -See [Harness execution](../harness-execution.md). +For dedicated Codex `api_key` authentication, `runtime.codexOpenaiBaseUrl` selects +an HTTPS streaming Responses endpoint ending in `/v1` (including `/api/v1`). +Credentials, wildcards, queries, and fragments are rejected. Omission retains +OpenAI's default; bound OpenShell sources select their own endpoint. OAuth, +account logins, and OpenClaw ignore this option. Only the Harness receives the +model key. Private Gateway configuration qualifies native IDs without changing +their namespaces or admitted Configuration; its provider uses authenticated +app-server transport and fail-closed HTTP. See [Harness execution](../harness-execution.md). ### Authentication @@ -280,25 +273,19 @@ absent. ### Plugin startup status -Compute-owned embedded or dedicated OpenClaw and dedicated Codex runtimes publish a private -current-startup result after attempting requested plugins and verifying effective -configuration. It identifies the revision, runtime instance, successful -selection IDs, and the [plugin warnings](../agent-plugins.md#lifecycle) +Compute-owned OpenClaw and Codex runtimes publish private startup results after +attempting plugins and verifying configuration: revision, runtime instance, +successful selection IDs, and [warnings](../agent-plugins.md#lifecycle) (`PLUGIN_INSTALL_FAILED` or `PLUGIN_AUTH_REQUIRED`) for disabled selections. +Compute reads the exact workload through the authenticated Pod proxy under +tenant-local RBAC; workloads receive no Kubernetes write credentials. The endpoint +is not public. Ownership, startup identity, selection keys, and warning codes +must match; missing, malformed, or foreign status cannot establish readiness. -Kubernetes Compute reads the exact owned workload's status endpoint through the -authenticated Kubernetes Pod proxy, which tenant-local controller RBAC permits; -workload ServiceAccounts receive no Kubernetes write credentials. The endpoint -is not part of the public gateway API. Compute validates workload ownership, -startup identity, admitted selection keys, and closed warning codes. Missing, -malformed, or foreign status cannot establish readiness. - -For embedded OpenClaw, startup explicitly disables failed plugin entries and -removes their managed tool allowances before starting the gateway. For dedicated -Codex, the separate gateway applies the Agent's current result to its bridge -configuration before serving and refreshes that configuration after a changed -restart result. Failed-only Codex app bindings are disabled; successful selections -retain their admitted policy, including shared app bindings they require. +Embedded OpenClaw disables failed plugins and their managed tool allowances before +Gateway startup. Dedicated Codex applies current results before serving and +refreshes them after changed restart results. Failed-only app bindings are disabled; +successful selections retain admitted policy and required shared bindings. The Codex app-server credential derives from the Agent's transport Secret, revision, and startup identity, so a gateway configured for the previous startup @@ -306,11 +293,10 @@ cannot authenticate to a restarted Agent. Its supervisor obtains the new status, applies the matching exclusions, and respawns the gateway process with the new credential, closing the interval before its next status poll. -The worker records warnings with successful deployment completion under its live -claim; a runtime restart recomputes status instead of preserving the first -failure. There are no plugin receipt ConfigMaps, Pod finalizers, failure latches, -or post-commit acknowledgment steps. This behavior does not change requested revision -selections, uninstall account-wide plugins, or promise rollback. +The worker commits warnings with successful deployment under its live claim; +restarts recompute status. No receipt ConfigMaps, finalizers, failure latches, or +post-commit acknowledgments are used. Requested selections and account-wide +installations remain unchanged; rollback is not promised. ### Current runtime diagnostics diff --git a/docs/reference/drivers/openshell-credential-gateway.md b/docs/reference/drivers/openshell-credential-gateway.md index f38a73657c..06c88eed04 100644 --- a/docs/reference/drivers/openshell-credential-gateway.md +++ b/docs/reference/drivers/openshell-credential-gateway.md @@ -69,7 +69,9 @@ Sandboxes and reads attachment status. Allow both to reach the gateway. When omitted, `base_url` defaults to `https://api.openai.com/v1`. Set it to an HTTPS OpenAI-compatible endpoint ending in `/v1` to use a different service. -Path prefixes such as `/api/v1` are supported; wildcard paths are rejected. +Path prefixes such as `/api/v1` are supported. Wildcards, URL credentials, +queries, and fragments are rejected with `400 INVALID_REQUEST` before Secret +reads or persistence. The Driver and Compute share URL validation and normalization. Codex requires that endpoint to implement the OpenAI Responses API. The selected endpoint is part of the source configuration and cannot be changed by updating its Secret; register a new source to change it. @@ -112,6 +114,12 @@ keeps the previous value until it restarts. provider using that endpoint profile remains, it also deletes the profile, because OpenShell cannot delete a Workspace that still holds profiles. +An invalid persisted endpoint cannot identify an owned profile. If the gateway +confirms that the source's provider is absent, deletion leaves all profiles alone +and removes the OCC record after its registration fence. If a provider exists, +status is `failed` and deletion refuses to guess its ownership. Unknown source +types and gateway errors also remain failures, not evidence of absence. + For a revision, `attachForRevision` returns each source's provider name. The OpenShell SandboxDriver appends those names to `SandboxSpec.providers`. `attachmentStatus` calls `GetSandboxProviderStatus` for each provider and maps diff --git a/docs/testing/kubernetes.md b/docs/testing/kubernetes.md index 8a187ac60f..bd0c8894d3 100644 --- a/docs/testing/kubernetes.md +++ b/docs/testing/kubernetes.md @@ -134,12 +134,11 @@ For a custom Codex Responses endpoint, use `--base-url` and the native ./scripts/k3d --base-url https://openrouter.ai/api/v1 --model-id z-ai/glm-5.3-flash ``` -Set `OPENAI_API_KEY` privately to the endpoint's key. Flags override -`OCC_TEST_CODEX_OPENAI_BASE_URL` and `OCC_TEST_OPENAI_MODEL`, including prepared -environment records; environment-only use remains supported. Model IDs retain -their full namespace. These flags select the Codex path without OpenShell and -are not accepted by inspection or cleanup commands. After source changes, use -fresh helper state so both images are rebuilt; reusing state retains its images. +Set `OPENAI_API_KEY` privately. Flags override `OCC_TEST_CODEX_OPENAI_BASE_URL` and +`OCC_TEST_OPENAI_MODEL`, including prepared records; environment-only input and +complete model namespaces remain supported. These flags require Codex without +OpenShell, not inspection/cleanup commands. Use fresh state after source changes +to rebuild both images. When `OPENAI_API_KEY` is unset, the interactive `demo` command prompts for it without echoing the value; `test` requires the variable and `reset` does not. A @@ -150,16 +149,13 @@ The helper requires k3d, `kubectl`, Helm, OpenSSL, and Docker Compose or `podman-compose`. It prefers a running Podman API socket unless `DOCKER_HOST` selects an engine. Set `OCC_K3D_CONTAINER_ENGINE=podman` or `docker` to override detection. -Preparation state is private to the selected engine under -`${XDG_STATE_HOME:-$HOME/.local/state}/openclaw-enterprise/k3d--codex`. -Set `OCC_K3D_STATE_DIR` to an absolute path to override it. Later runs reuse -the prepared cluster, database, images, Envoy Gateway, cert-manager, and the -disposable private-routing CA. The helper has no image upgrade command; for a -persistent Helm installation, see -[local k3d image upgrades](../guides/deploy/local-k3d-image-upgrade.md). The -helper builds the current checkout and ignores Kubernetes image selectors -inherited from an earlier test shell. -Run `./scripts/k3d down` before reusing state prepared without workspace routing. +Engine-private state lives under +`${XDG_STATE_HOME:-$HOME/.local/state}/openclaw-enterprise/k3d--codex`; +`OCC_K3D_STATE_DIR` overrides it with an absolute path. Reuse retains the cluster, +database, images, Envoy, cert-manager, and disposable routing CA. Initial builds +use the current checkout, ignoring inherited Kubernetes image selectors. +There is no helper image-upgrade command; see [Helm image upgrades](../guides/deploy/local-k3d-image-upgrade.md). +Run `./scripts/k3d down` before reusing state lacking workspace routing. To clear an interrupted test or rerun against a fresh database while preserving the PostgreSQL service, cluster, and imported images: @@ -172,11 +168,10 @@ Reset deletes only helper test Namespaces, such as `oce-production-*`, `oce-openshell-*`, and `oce-ns-*`, from the helper-owned cluster. It drops and recreates only the database recorded in the helper's private state. -The default command starts the OCC API in Kubernetes, creates a dedicated Codex -Agent, and completes a model turn. It serves the OpenClaw Control UI at -`http://127.0.0.1:18888` and the OCC console at `http://127.0.0.1:18889`. -It prints the temporary OCC username and a command to copy its password from -the mode-`0600` `demo.json` file; it never prints passwords. +The default command starts Kubernetes OCC and a dedicated Codex Agent, completes +a model turn, and serves Control UI at `http://127.0.0.1:18888` and OCC at +`http://127.0.0.1:18889`. It prints the temporary username and a password-copy +command for mode-`0600` `demo.json`, never the password. Pass `--harness openclaw` for the verification-only [native OpenClaw Harness](openshell.md#native-openclaw-with-k3d). @@ -304,12 +299,10 @@ recovery. Unsupported images fail. ### Transcript persistence -When `OCC_TEST_CODEX_OPENAI_BASE_URL` selects a custom endpoint, the dedicated -Gateway model-turn case checks Codex's recorded native provider and complete model -ID. The retained-thread case repeats those checks after Gateway replacement, -exercising stock OpenClaw's new-thread, resume, and subsequent-turn resolution. -Use a namespaced `OCC_TEST_OPENAI_MODEL` to protect namespace preservation. A -direct Pod-loopback Codex turn does not exercise that Gateway translation. +With `OCC_TEST_CODEX_OPENAI_BASE_URL`, Gateway model turns and retained threads +after Gateway replacement verify Codex's native provider and complete model ID: +new-thread, resume, and subsequent-turn resolution. Use a namespaced +`OCC_TEST_OPENAI_MODEL`. Pod-loopback turns do not prove Gateway translation. Both Harness topologies require SQLite transcripts. Persistence cases query `session_nodes` and `transcript_events`, then verify conversation history and media diff --git a/docs/testing/openshell.md b/docs/testing/openshell.md index fa776474c3..06d77954a9 100644 --- a/docs/testing/openshell.md +++ b/docs/testing/openshell.md @@ -292,7 +292,7 @@ scoped environment file for this suite. | `OPENAI_API_KEY` | Existing authorized provider credential, registered as a credential source for the required real model turn. | | `OCC_TEST_OPENSHELL_HARNESS` | `codex` (default) selects the app-server proof; `openclaw` selects the dedicated native worker without an inbound Harness exposure. | | `OCC_TEST_OPENAI_MODEL` | Authorized provider model; defaults to `gpt-6-astra`. | -| `OCC_TEST_OPENSHELL_MODEL_BASE_URL` | Optional HTTPS `/v1` endpoint for the Codex credential source; requires Responses API compatibility. | +| `OCC_TEST_OPENSHELL_MODEL_BASE_URL` | Optional HTTPS `/v1` endpoint for the Codex credential source; requires Responses API compatibility. | | `OCC_TEST_KUBERNETES_KUBECONFIG` | Absolute kubeconfig path for the dedicated disposable k3d cluster. | | `OCC_TEST_KUBERNETES_CONTEXT` | Explicit `k3d-*` context with a verified loopback HTTPS API. | | `OCC_TEST_KUBERNETES_GATEWAY_IMAGE` | Imported immutable real OpenClaw gateway image; `OCC_TEST_KUBERNETES_RUNTIME_IMAGE` is accepted as a fallback. | diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fcdc548a31..0c616d6bcb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1213,6 +1213,8 @@ export interface CredentialAttachmentStatus { export interface CredentialGatewayDriver extends Driver { readonly capability: "credential_gateway"; listSourceTypes(context: CredentialGatewayContext): Promise; + /** Validate nonsecret configuration without I/O, before OCC reads Secrets or stores a record. */ + validateSourceConfig(input: Pick): void; registerSource( context: CredentialSourceContext, input: CredentialSourceInput, diff --git a/packages/occ/src/driver-contract.ts b/packages/occ/src/driver-contract.ts index 65020c8b33..1ae7665384 100644 --- a/packages/occ/src/driver-contract.ts +++ b/packages/occ/src/driver-contract.ts @@ -54,6 +54,7 @@ export function driverHasCapabilityContract(driver: Driver): boolean { if (driver.capability === "credential_gateway") { return [ "listSourceTypes", + "validateSourceConfig", "registerSource", "updateSource", "rotateSource", diff --git a/packages/occ/src/errors.ts b/packages/occ/src/errors.ts index 4ac41c349f..1033c7fd40 100644 --- a/packages/occ/src/errors.ts +++ b/packages/occ/src/errors.ts @@ -511,6 +511,17 @@ export class CredentialGatewayNotConfiguredError extends Error { } } +/** A Driver rejected nonsecret source configuration before registration. */ +export class CredentialSourceConfigError extends Error { + readonly field: string; + + constructor(field: string, message: string) { + super(message); + this.name = "CredentialSourceConfigError"; + this.field = field; + } +} + export class DriverSelectionError extends Error { constructor(message: string) { super(message); diff --git a/packages/occ/src/index.ts b/packages/occ/src/index.ts index 66418ed626..15b3edcd49 100644 --- a/packages/occ/src/index.ts +++ b/packages/occ/src/index.ts @@ -252,6 +252,7 @@ export { ChannelCredentialError, ConfigurationHarnessError, CredentialGatewayNotConfiguredError, + CredentialSourceConfigError, IAMAccessBindingRoleError, IAMPolicyValidationError, IAMRoleInUseError, @@ -3646,6 +3647,7 @@ export class OpenClawController { const type = await this.credentialSourceType(selected, input.type); credentialSourceFieldsMatch("config", type.config, config); credentialSourceFieldsMatch("secrets", type.secrets, secretRefs); + selected.validateSourceConfig({ type: type.type, config }); const read = await this.readCredentialSourceSecrets(state, principalId, locked, secretRefs); const registering = await state.credentialSources.createCredentialSource( Object.freeze({ diff --git a/scripts/ci/test-suites/checks-baseline-1.json b/scripts/ci/test-suites/checks-baseline-1.json index de2904d39b..e6cc8a06ba 100644 --- a/scripts/ci/test-suites/checks-baseline-1.json +++ b/scripts/ci/test-suites/checks-baseline-1.json @@ -259,6 +259,9 @@ { "path": "tests/integration/secret-api.test.mjs" }, + { + "path": "tests/integration/credential-source-api.test.mjs" + }, { "path": "tests/integration/plugin-discovery-api.test.mjs" }, diff --git a/tests/conformance/credential-source-occ.test.mjs b/tests/conformance/credential-source-occ.test.mjs index 35d856c5d4..474c3e434c 100644 --- a/tests/conformance/credential-source-occ.test.mjs +++ b/tests/conformance/credential-source-occ.test.mjs @@ -47,6 +47,7 @@ function createTestCredentialGateway(options = {}) { create(); } }, + validateSourceConfig() {}, async listSourceTypes() { return [ { diff --git a/tests/conformance/kubernetes-compute.test.mjs b/tests/conformance/kubernetes-compute.test.mjs index 30aa09009a..e3f5a97fe8 100644 --- a/tests/conformance/kubernetes-compute.test.mjs +++ b/tests/conformance/kubernetes-compute.test.mjs @@ -1264,7 +1264,7 @@ function dedicatedFirstDeployFixture({ statusProxy = true, clock, modelEndpoint test("custom Codex endpoints compile explicit providers without changing the admitted model IDs", async () => { for (const sourceProvider of ["codex", "openai"]) { const fixture = dedicatedFirstDeployFixture({ - modelEndpoint: "https://models.example.test/api/v1", + modelEndpoint: "https://MODELS.example.test:443/api/v1/", }); const primary = `${sourceProvider}/vendor/organization/model`; const fallback = `${sourceProvider}/codex/native`; diff --git a/tests/integration/credential-source-api.test.mjs b/tests/integration/credential-source-api.test.mjs new file mode 100644 index 0000000000..83562c4a62 --- /dev/null +++ b/tests/integration/credential-source-api.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { + createOpenShellBackend, + openShellProviderName, +} from "../../apps/controller/src/backends/openshell.ts"; +import { OpenShellCredentialGatewayDriver } from "../../apps/controller/src/drivers/credential-gateway/openshell.ts"; +import { OpenShellProviderAlreadyExistsError } from "../../apps/controller/src/drivers/sandbox/openshell-gateway-client.ts"; +import { createConsoleAppFixture } from "../helpers/console-app.mjs"; +import { createDevelopmentComputeDriver } from "../helpers/development.mjs"; + +// Gateway storage and Compute placement are test doubles. Requests use the real +// Fastify app, authentication, IAM, OCC transactions, and OpenShell Driver. +async function fixture(t) { + const providers = new Map(); + const profiles = new Map(); + const mutations = []; + const key = (workspace, name) => workspace + "/" + name; + const client = { + async getProviderProfile(workspace, id) { + return profiles.get(key(workspace, id)); + }, + async importProviderProfile(workspace, profile) { + mutations.push("importProfile"); + profiles.set(key(workspace, profile.id), structuredClone(profile)); + }, + async deleteProviderProfile(workspace, id) { + mutations.push("deleteProfile"); + profiles.delete(key(workspace, id)); + }, + async createProvider(input) { + const id = key(input.workspace, input.name); + if (providers.has(id)) { + throw new OpenShellProviderAlreadyExistsError(); + } + mutations.push("createProvider"); + providers.set(id, structuredClone(input)); + return providers.get(id); + }, + async getProvider(workspace, name) { + return providers.get(key(workspace, name)); + }, + async listProviders(workspace) { + return [...providers.values()].filter((provider) => provider.workspace === workspace); + }, + async deleteProvider(workspace, name) { + mutations.push("deleteProvider"); + providers.delete(key(workspace, name)); + }, + async updateProviderCredentials(workspace, name, credentials) { + mutations.push("updateProvider"); + providers.get(key(workspace, name)).credentials = structuredClone(credentials); + }, + close() {}, + }; + const backend = createOpenShellBackend( + { + id: "openshell-test", + type: "openshell", + configuration: { endpoint: "http://127.0.0.1:1" }, + drivers: { credential_gateway: "openshell-credentials", sandbox: "openshell-sandbox" }, + }, + { gatewayClient: client }, + ); + const driver = new OpenShellCredentialGatewayDriver( + { binaries: ["/app/bin/codex"] }, + { + id: "openshell-credentials", + backend, + }, + ); + let now = Date.parse("2026-10-05T12:00:00Z"); + const computeDriver = { + ...createDevelopmentComputeDriver({ id: "credential-source-compute" }), + async resolveSandboxNamespace(namespace) { + return namespace; + }, + }; + const app = await createConsoleAppFixture(t, { now: () => new Date(now), computeDriver }); + await app.bootstrap(); + app.controller.registerDriver(driver); + app.controller.selectDriver("credential_gateway", driver.id); + const namespace = await app.createNamespace("credential-test", { ready: true }); + const path = "/namespaces/" + namespace.id + "/credential-sources"; + const secret = await app.createSecret(namespace.id, "model-key", "synthetic-model-key"); + const create = (name, config = {}) => + app.request("POST", path, { + body: { name, type: "openai", config, secrets: { api_key: secret.ref } }, + }); + return { + ...app, + driver, + client, + providers, + profiles, + mutations, + namespace, + path, + create, + passRegistrationFence() { + now += 71_000; + }, + async seedInvalid(type = "openai") { + // Reproduce the historical failed registration: no remote mutation ever ran, + // but OCC retained a deleting row. Its age is beyond the registration fence. + const source = { + id: "cs_" + randomUUID(), + namespaceId: namespace.id, + name: "invalid-" + randomUUID(), + type, + config: { base_url: "http://" }, + secrets: { api_key: secret.ref }, + driverId: driver.id, + state: "registering", + createdAt: new Date(now - 71_000).toISOString(), + }; + await app.controller.transact(async (unit) => { + await unit.credentialSources.createCredentialSource(source); + await unit.credentialSources.markCredentialSourceDeleting(namespace.id, source.id); + }); + return source; + }, + }; +} + +test("credential source HTTP admission rejects invalid endpoints without retaining records", async (t) => { + const f = await fixture(t); + for (const base_url of [ + "http://", + "https://models.example.test", + "http://models.example.test/v1", + "https://models.example.test/*/v1", + "https://models.example.test/v1?option=value", + "https://models.example.test/v1#fragment", + ]) { + const response = await f.create("invalid", { base_url }); + // Assert absence independently of the status: the original bug left a deleting row. + assert.deepEqual((await f.request("GET", f.path)).data, [], base_url); + assert.equal(response.status, 400, JSON.stringify(response.body)); + assert.equal(response.body.error.code, "INVALID_REQUEST"); + assert.deepEqual(response.body.error.details, [ + { path: "/config/base_url", code: "INVALID_VALUE" }, + ]); + assert.match(response.body.error.message, new RegExp("HTTPS.*/v1")); + } + assert.deepEqual(f.mutations, []); + assert.equal(f.providers.size, 0); + assert.equal(f.profiles.size, 0); +}); + +test("credential source HTTP deletion recovers invalid stored endpoints without touching profiles", async (t) => { + const f = await fixture(t); + const healthy = await f.create("healthy"); + assert.equal(healthy.status, 201, JSON.stringify(healthy.body)); + const profilesBefore = structuredClone([...f.profiles]); + const source = await f.seedInvalid(); + const path = f.path + "/" + source.id; + const read = await f.request("GET", path); + assert.equal(read.status, 200); + assert.equal(read.data.status.state, "absent"); + assert.equal((await f.request("DELETE", path)).status, 204); + assert.equal((await f.request("GET", path)).status, 404); + assert.deepEqual([...f.profiles], profilesBefore); + assert.equal((await f.request("GET", f.path + "/" + healthy.data.id)).data.status.state, "ready"); + + // An unexpected remote resource is not evidence of absence. Even source labels + // cannot prove its profile from an invalid endpoint; preserve both objects. + const collision = await f.seedInvalid(); + const existing = [...f.providers.values()][0]; + const foreign = { + ...existing, + name: openShellProviderName(collision.id), + labels: { ...existing.labels, "openclaw.dev/credential-source-id": collision.id }, + }; + f.providers.set(foreign.workspace + "/" + foreign.name, foreign); + const collisionPath = f.path + "/" + collision.id; + assert.equal((await f.request("GET", collisionPath)).data.status.state, "failed"); + assert.notEqual((await f.request("DELETE", collisionPath)).status, 204); + assert.equal((await f.request("GET", collisionPath)).data.state, "deleting"); + assert.ok([...f.providers.values()].includes(foreign)); + assert.deepEqual([...f.profiles], profilesBefore); + + const unknown = await f.seedInvalid("unknown"); + assert.notEqual((await f.request("DELETE", f.path + "/" + unknown.id)).status, 204); + assert.equal((await f.request("GET", f.path + "/" + unknown.id)).data.state, "deleting"); +}); + +test("OpenShell custom and default credential profiles keep independent ownership and lifecycle", async (t) => { + const f = await fixture(t); + const standard = await f.create("default"); + const custom = await f.create("custom", { base_url: "https://MODELS.example.test:8443/api/v1/" }); + const shared = await f.create("shared", { base_url: "https://models.example.test:8443/api/v1" }); + for (const result of [standard, custom, shared]) { + assert.equal(result.status, 201, JSON.stringify(result.body)); + assert.equal(result.data.status.state, "ready"); + assert.equal( + (await f.request("GET", f.path + "/" + result.data.id)).data.status.state, + "ready", + ); + } + const customProfile = [...f.profiles.values()].find((profile) => profile.id !== "oce-openai"); + assert.equal(f.profiles.size, 2); + assert.match(customProfile.id, /^oce-openai-[0-9a-f]{12}$/); + assert.deepEqual(customProfile.endpoints, [ + { host: "models.example.test", port: 8443, protocol: "rest", path: "/api/v1/**" }, + ]); + assert.deepEqual(customProfile.binaries, ["/app/bin/codex"]); + const provider = [...f.providers.values()].find( + (row) => row.name === openShellProviderName(custom.data.id), + ); + const source = await f.controller.transact((unit) => + unit.credentialSources.findCredentialSource(f.namespace.id, custom.data.id), + ); + const context = { + namespace: { ...f.namespace, name: provider.workspace }, + source, + signal: AbortSignal.timeout(5000), + }; + // Native OpenClaw cannot use a custom endpoint. Codex can attach only the source's profile. + await assert.rejects( + f.driver.attachForRevision({ + ...context, + sources: [source], + revision: { harness: { id: "openclaw", mode: "dedicated" } }, + }), + /default OpenAI endpoint/, + ); + assert.deepEqual( + await f.driver.attachForRevision({ + ...context, + sources: [source], + revision: { harness: { id: "codex", mode: "dedicated" } }, + }), + [{ sourceId: custom.data.id, ref: provider.name }], + ); + provider.type = "oce-openai"; + assert.equal((await f.request("GET", f.path + "/" + custom.data.id)).data.status.state, "failed"); + await assert.rejects( + f.driver.registerSource(context, { + type: "openai", + config: custom.data.config, + secrets: { api_key: "synthetic-model-key" }, + }), + /not owned/, + ); + await assert.rejects(f.driver.removeSource(context), /not owned/); + provider.type = customProfile.id; + assert.equal((await f.request("PATCH", f.path + "/" + custom.data.id, { body: {} })).status, 200); + f.passRegistrationFence(); + assert.equal((await f.request("DELETE", f.path + "/" + custom.data.id)).status, 204); + assert.equal(f.profiles.size, 2, "another source still needs the custom profile"); + assert.equal((await f.request("DELETE", f.path + "/" + shared.data.id)).status, 204); + assert.deepEqual( + [...f.profiles.values()].map((profile) => profile.id), + ["oce-openai"], + ); + assert.equal( + (await f.request("GET", f.path + "/" + standard.data.id)).data.status.state, + "ready", + ); + assert.equal((await f.request("DELETE", f.path + "/" + standard.data.id)).status, 204); + assert.equal(f.profiles.size, 0); + assert.equal(f.providers.size, 0); +}); diff --git a/tests/integration/postgres-worker-agent-revision.test.mjs b/tests/integration/postgres-worker-agent-revision.test.mjs index b201401654..a5e9d5ee06 100644 --- a/tests/integration/postgres-worker-agent-revision.test.mjs +++ b/tests/integration/postgres-worker-agent-revision.test.mjs @@ -481,6 +481,7 @@ function withCredentialGateway(drivers) { id: CREDENTIAL_GATEWAY_FIXTURE_ID, capability: "credential_gateway", implementation: "credential-gateway-worker-fixture", + validateSourceConfig() {}, async listSourceTypes() { return []; }, diff --git a/tests/integration/secret-api.test.mjs b/tests/integration/secret-api.test.mjs index 6400cd853e..63cc93f2be 100644 --- a/tests/integration/secret-api.test.mjs +++ b/tests/integration/secret-api.test.mjs @@ -1141,6 +1141,7 @@ function createRecordingCredentialGateway() { capability: "credential_gateway", implementation: "test-recording-gateway", stored, + validateSourceConfig() {}, async listSourceTypes() { return [ { From 31b1b6a9ab59f219d0fbe3d44b1550f8c8f2fe4a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:17:41 +0000 Subject: [PATCH 3/5] fix(credentials): reject pattern-bearing endpoint hosts Reject normalized hostname stars and reserved port zero before credential source persistence. Refuse bracketed IPv6 representations specifically at the OpenShell provider boundary, preserving Compute URL admission and ownership-fenced cleanup. Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> --- .../src/drivers/compute/kubernetes/index.ts | 6 +- .../drivers/credential-gateway/openshell.ts | 40 +++---- .../controller/src/drivers/openai-endpoint.ts | 3 + docs/flows/credential-source-lifecycle.md | 12 +- docs/reference/drivers/kubernetes-compute.md | 2 +- .../drivers/openshell-credential-gateway.md | 9 +- tests/conformance/kubernetes-compute.test.mjs | 18 +++ .../credential-source-api.test.mjs | 113 +++++++++++++----- 8 files changed, 149 insertions(+), 54 deletions(-) diff --git a/apps/controller/src/drivers/compute/kubernetes/index.ts b/apps/controller/src/drivers/compute/kubernetes/index.ts index 7aa845f0cc..cac19a51c0 100644 --- a/apps/controller/src/drivers/compute/kubernetes/index.ts +++ b/apps/controller/src/drivers/compute/kubernetes/index.ts @@ -2322,7 +2322,7 @@ export class KubernetesComputeDriver implements ComputeDriver { normalizeOpenAiBaseUrl(options.runtime.codexOpenaiBaseUrl) === undefined ) { throw new ConfigurationFailure( - "Codex model endpoint requires HTTPS and a path ending in /v1 without credentials, wildcards, query, or fragment.", + "Codex model endpoint requires HTTPS, a nonzero port, and a path ending in /v1 without credentials, wildcards, query, or fragment.", ); } if (options.runtime.codexSeccompProfile !== undefined) { @@ -10099,7 +10099,9 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`; if (openaiBaseUrl !== undefined) { const normalized = normalizeOpenAiBaseUrl(openaiBaseUrl); if (normalized === undefined) { - throw new ConfigurationFailure("Codex model endpoint requires a valid HTTPS /v1 URL."); + throw new ConfigurationFailure( + "Codex model endpoint requires a valid HTTPS /v1 URL with a nonzero port and no hostname wildcards.", + ); } openaiBaseUrl = normalized; } diff --git a/apps/controller/src/drivers/credential-gateway/openshell.ts b/apps/controller/src/drivers/credential-gateway/openshell.ts index 0cca6f3f01..56ff654500 100644 --- a/apps/controller/src/drivers/credential-gateway/openshell.ts +++ b/apps/controller/src/drivers/credential-gateway/openshell.ts @@ -49,6 +49,8 @@ const NAMESPACE_ID_LABEL = "openclaw.dev/namespace-id"; const PROFILE_DIGEST_ANNOTATION = "openclaw.dev/profile-digest"; const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1"; const OPENAI_BASE_URL_CONFIG = "base_url"; +const SOURCE_ENDPOINT_REQUIREMENTS = + "The OpenShell endpoint requires HTTPS, a nonzero port, and a /v1 path. Wildcards, bracketed IPv6 hosts, URL credentials, query, and fragment are unsupported."; interface OpenShellSourceType { readonly catalog: CredentialSourceType; @@ -160,12 +162,20 @@ function ownedBy( ); } -function sourceBaseUrl(config: Readonly>): string { +function normalizedSourceBaseUrl(config: Readonly>): string | undefined { const baseUrl = normalizeOpenAiBaseUrl(config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL); + // IPv6 brackets are URI syntax, but HostPattern interprets them as character classes. + // Do not glob-escape them without an exact representation shared by policy matching. + if (baseUrl === undefined || new URL(baseUrl).hostname.startsWith("[")) { + return undefined; + } + return baseUrl; +} + +function sourceBaseUrl(config: Readonly>): string { + const baseUrl = normalizedSourceBaseUrl(config); if (baseUrl === undefined) { - throw new ScopeViolationError( - "The OpenAI-compatible endpoint must use HTTPS and a path ending in /v1 without wildcards, credentials, query, or fragment.", - ); + throw new ScopeViolationError(SOURCE_ENDPOINT_REQUIREMENTS); } return baseUrl; } @@ -225,14 +235,8 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver validateSourceConfig(input: Pick): void { sourceType(input.type); - if ( - normalizeOpenAiBaseUrl(input.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL) === - undefined - ) { - throw new CredentialSourceConfigError( - OPENAI_BASE_URL_CONFIG, - "The OpenAI-compatible endpoint must use HTTPS and a path ending in /v1 without wildcards, credentials, query, or fragment.", - ); + if (normalizedSourceBaseUrl(input.config) === undefined) { + throw new CredentialSourceConfigError(OPENAI_BASE_URL_CONFIG, SOURCE_ENDPOINT_REQUIREMENTS); } } @@ -315,9 +319,7 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver async sourceStatus(context: CredentialSourceContext): Promise { const type = sourceType(context.source.type); - const baseUrl = normalizeOpenAiBaseUrl( - context.source.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL, - ); + const baseUrl = normalizedSourceBaseUrl(context.source.config); const provider = await this.client(context).getProvider( openShellWorkspaceName(context.namespace), openShellProviderName(context.source.id), @@ -337,15 +339,13 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver async removeSource(context: CredentialSourceContext): Promise { const type = sourceType(context.source.type); - const baseUrl = normalizeOpenAiBaseUrl( - context.source.config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL, - ); + const baseUrl = normalizedSourceBaseUrl(context.source.config); const workspace = openShellWorkspaceName(context.namespace); const name = openShellProviderName(context.source.id); const client = this.client(context); const existing = await client.getProvider(workspace, name, context.signal); - // Invalid configuration cannot have created a profile, but a real remote - // collision is not absence. Never guess its owner or delete another profile. + // Invalid configuration cannot identify a profile safely; a real remote + // resource is not absence. Never guess its owner or delete another profile. if (baseUrl === undefined) { if (existing !== undefined) { throw new ScopeViolationError("The OpenShell provider's ownership cannot be verified."); diff --git a/apps/controller/src/drivers/openai-endpoint.ts b/apps/controller/src/drivers/openai-endpoint.ts index 4ab79b7039..c43f0f4c84 100644 --- a/apps/controller/src/drivers/openai-endpoint.ts +++ b/apps/controller/src/drivers/openai-endpoint.ts @@ -10,6 +10,9 @@ export function normalizeOpenAiBaseUrl(value: string): string | undefined { if ( endpoint.protocol !== "https:" || endpoint.hostname.length === 0 || + // WHATWG decodes hostname escapes but permits stars that profiles treat as patterns. + endpoint.hostname.includes("*") || + endpoint.port === "0" || endpoint.username !== "" || endpoint.password !== "" || !path.endsWith("/v1") || diff --git a/docs/flows/credential-source-lifecycle.md b/docs/flows/credential-source-lifecycle.md index f175f0e8d1..abb5d48b42 100644 --- a/docs/flows/credential-source-lifecycle.md +++ b/docs/flows/credential-source-lifecycle.md @@ -1,6 +1,6 @@ --- created: "2026-09-26" -updated: 2026-10-05 +updated: 2026-10-06 last_updated_session: agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 --- @@ -77,6 +77,14 @@ unknown type, an unknown field, or a missing required field with `ScopeViolationError` (`404`). It then calls the Driver's side-effect-free `validateSourceConfig`. Invalid values return `CredentialSourceConfigError` (`400 INVALID_REQUEST`) before any Secret read, record creation, or gateway write. +The shared `apps/controller/src/drivers/openai-endpoint.ts:normalizeOpenAiBaseUrl` +parses URLs before rejecting wildcard hostnames (including decoded percent +escapes), wildcard paths, and port `0`. OpenShell additionally uses +`apps/controller/src/drivers/credential-gateway/openshell.ts:normalizedSourceBaseUrl` +to reject bracketed IPv6 hosts: its profile matcher interprets brackets as +character classes, not literal URI syntax. Compute URL validation still accepts +IPv6. Registration and attachment require the OpenShell-safe representation; +status and deletion retain the no-guessed-owner behavior for invalid stored sources. ### 2. Read Secret values @@ -294,6 +302,8 @@ than re-attach the source. ## Changelog +- 2026-10-06 16:12: Reject normalized hostname stars, zero ports, and OpenShell bracket hosts before credential-source persistence. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 129b9b98812a861cfbdcec386daf5258902ebba8) + - 2026-10-05 11:36: Validate source configuration before persistence and recover invalid rows without guessing remote ownership. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 9958ef0412565864efba7b13995536d7c2a51d22) - 2026-10-03 18:00: Registration and update reject a Secret reference to another Namespace as an invalid request instead of not-found, as Secret bindings do. (binding-400b) diff --git a/docs/reference/drivers/kubernetes-compute.md b/docs/reference/drivers/kubernetes-compute.md index e85b56770d..ac0b0e3243 100644 --- a/docs/reference/drivers/kubernetes-compute.md +++ b/docs/reference/drivers/kubernetes-compute.md @@ -190,7 +190,7 @@ workers, so size the limit against the Agent workload's CPU and memory limits. For dedicated Codex `api_key` authentication, `runtime.codexOpenaiBaseUrl` selects an HTTPS streaming Responses endpoint ending in `/v1` (including `/api/v1`). -Credentials, wildcards, queries, and fragments are rejected. Omission retains +Hostname/path wildcards, port `0`, credentials, queries, and fragments are rejected. Omission retains OpenAI's default; bound OpenShell sources select their own endpoint. OAuth, account logins, and OpenClaw ignore this option. Only the Harness receives the model key. Private Gateway configuration qualifies native IDs without changing diff --git a/docs/reference/drivers/openshell-credential-gateway.md b/docs/reference/drivers/openshell-credential-gateway.md index 06c88eed04..0631a92e11 100644 --- a/docs/reference/drivers/openshell-credential-gateway.md +++ b/docs/reference/drivers/openshell-credential-gateway.md @@ -69,9 +69,12 @@ Sandboxes and reads attachment status. Allow both to reach the gateway. When omitted, `base_url` defaults to `https://api.openai.com/v1`. Set it to an HTTPS OpenAI-compatible endpoint ending in `/v1` to use a different service. -Path prefixes such as `/api/v1` are supported. Wildcards, URL credentials, -queries, and fragments are rejected with `400 INVALID_REQUEST` before Secret -reads or persistence. The Driver and Compute share URL validation and normalization. +Path prefixes such as `/api/v1` are supported. Hostname/path wildcards, port `0`, +URL credentials, queries, and fragments are rejected with `400 INVALID_REQUEST` +before Secret reads or persistence. The Driver and Compute share URL normalization; +hostname percent escapes are decoded before checking for wildcard stars. +The Driver rejects bracketed IPv6 hosts, which the upstream profile matcher treats as +character classes; Compute URL validation still accepts IPv6. Codex requires that endpoint to implement the OpenAI Responses API. The selected endpoint is part of the source configuration and cannot be changed by updating its Secret; register a new source to change it. diff --git a/tests/conformance/kubernetes-compute.test.mjs b/tests/conformance/kubernetes-compute.test.mjs index e3f5a97fe8..f3e2f6b532 100644 --- a/tests/conformance/kubernetes-compute.test.mjs +++ b/tests/conformance/kubernetes-compute.test.mjs @@ -4105,6 +4105,15 @@ test("the canonical Kubernetes runtime validates the dedicated Codex model endpo for (const codexOpenaiBaseUrl of [ "https://models.example.test/v1", "https://models.example.test/api/v1/", + "https://API.OpenAI.com:443/v1/", + "https://models.example.test:8443/v1", + "https://bücher.example.test/v1", + "https://192.0.2.1/v1", + // These concrete URL forms remain valid here; this does not prove provider + // support or reachability through a deployment's network policy. + "https://[2001:db8::1]/v1", + "https://[::1]/v1", + "https://models.example.test./v1", ]) { assert.doesNotThrow(() => createKubernetesComputeDriver(options({ runtime: { ...runtime, codexOpenaiBaseUrl } })), @@ -4121,6 +4130,15 @@ test("the canonical Kubernetes runtime validates the dedicated Codex model endpo }), "https://models.example.test/v2", "https://models.example.test/*/v1", + "https://**/v1", + "https://*.example.test/v1", + "https://ex*ample.test/v1", + "https://%2a%2A/v1", + "https://%2A.example.test/v1", + "https://ex%2aample.test/v1", + "https://*.example.test/v1", + "https://models.example.test:0/v1", + "https://models.example.test:000/v1", "https://models.example.test/v1?key=fixture", "https://models.example.test/v1#fragment", ]) { diff --git a/tests/integration/credential-source-api.test.mjs b/tests/integration/credential-source-api.test.mjs index 83562c4a62..a25fe6f20f 100644 --- a/tests/integration/credential-source-api.test.mjs +++ b/tests/integration/credential-source-api.test.mjs @@ -10,6 +10,7 @@ import { OpenShellCredentialGatewayDriver } from "../../apps/controller/src/driv import { OpenShellProviderAlreadyExistsError } from "../../apps/controller/src/drivers/sandbox/openshell-gateway-client.ts"; import { createConsoleAppFixture } from "../helpers/console-app.mjs"; import { createDevelopmentComputeDriver } from "../helpers/development.mjs"; +import { createTestSecretDriver } from "../helpers/secret-driver.mjs"; // Gateway storage and Compute placement are test doubles. Requests use the real // Fastify app, authentication, IAM, OCC transactions, and OpenShell Driver. @@ -78,7 +79,12 @@ async function fixture(t) { return namespace; }, }; - const app = await createConsoleAppFixture(t, { now: () => new Date(now), computeDriver }); + const secretDriver = createTestSecretDriver(); + const app = await createConsoleAppFixture(t, { + now: () => new Date(now), + computeDriver, + secretDriver, + }); await app.bootstrap(); app.controller.registerDriver(driver); app.controller.selectDriver("credential_gateway", driver.id); @@ -96,21 +102,22 @@ async function fixture(t) { providers, profiles, mutations, + secretDriver, namespace, path, create, passRegistrationFence() { now += 71_000; }, - async seedInvalid(type = "openai") { - // Reproduce the historical failed registration: no remote mutation ever ran, - // but OCC retained a deleting row. Its age is beyond the registration fence. + async seedInvalid(type = "openai", baseUrl = "http://") { + // Reproduce invalid persisted configuration beyond the registration fence. + // Each case decides whether the remote provider is absent or still present. const source = { id: "cs_" + randomUUID(), namespaceId: namespace.id, name: "invalid-" + randomUUID(), type, - config: { base_url: "http://" }, + config: { base_url: baseUrl }, secrets: { api_key: secret.ref }, driverId: driver.id, state: "registering", @@ -132,6 +139,18 @@ test("credential source HTTP admission rejects invalid endpoints without retaini "https://models.example.test", "http://models.example.test/v1", "https://models.example.test/*/v1", + "https://**/v1", + "https://*.example.test/v1", + "https://ex*ample.test/v1", + "https://%2a%2A/v1", + "https://%2A.example.test/v1", + "https://ex%2aample.test/v1", + "https://*.example.test/v1", + "https://models.example.test:0/v1", + "https://models.example.test:000/v1", + // URI-valid IPv6 brackets would be character classes in OpenShell host patterns. + "https://[2001:db8::1]/v1", + "https://[::1]/v1", "https://models.example.test/v1?option=value", "https://models.example.test/v1#fragment", ]) { @@ -144,7 +163,14 @@ test("credential source HTTP admission rejects invalid endpoints without retaini { path: "/config/base_url", code: "INVALID_VALUE" }, ]); assert.match(response.body.error.message, new RegExp("HTTPS.*/v1")); + if (base_url.startsWith("https://[")) { + assert.match(response.body.error.message, /bracketed IPv6 hosts.*unsupported/); + } } + assert.equal( + f.secretDriver.calls.some(({ operation }) => operation === "withValue"), + false, + ); assert.deepEqual(f.mutations, []); assert.equal(f.providers.size, 0); assert.equal(f.profiles.size, 0); @@ -155,32 +181,36 @@ test("credential source HTTP deletion recovers invalid stored endpoints without const healthy = await f.create("healthy"); assert.equal(healthy.status, 201, JSON.stringify(healthy.body)); const profilesBefore = structuredClone([...f.profiles]); - const source = await f.seedInvalid(); - const path = f.path + "/" + source.id; - const read = await f.request("GET", path); - assert.equal(read.status, 200); - assert.equal(read.data.status.state, "absent"); - assert.equal((await f.request("DELETE", path)).status, 204); - assert.equal((await f.request("GET", path)).status, 404); - assert.deepEqual([...f.profiles], profilesBefore); + for (const baseUrl of ["http://", "https://**/v1", "https://[2001:db8::1]/v1"]) { + const source = await f.seedInvalid("openai", baseUrl); + const path = f.path + "/" + source.id; + const read = await f.request("GET", path); + assert.equal(read.status, 200); + assert.equal(read.data.status.state, "absent"); + assert.equal((await f.request("DELETE", path)).status, 204); + assert.equal((await f.request("GET", path)).status, 404); + assert.deepEqual([...f.profiles], profilesBefore); + } assert.equal((await f.request("GET", f.path + "/" + healthy.data.id)).data.status.state, "ready"); // An unexpected remote resource is not evidence of absence. Even source labels // cannot prove its profile from an invalid endpoint; preserve both objects. - const collision = await f.seedInvalid(); - const existing = [...f.providers.values()][0]; - const foreign = { - ...existing, - name: openShellProviderName(collision.id), - labels: { ...existing.labels, "openclaw.dev/credential-source-id": collision.id }, - }; - f.providers.set(foreign.workspace + "/" + foreign.name, foreign); - const collisionPath = f.path + "/" + collision.id; - assert.equal((await f.request("GET", collisionPath)).data.status.state, "failed"); - assert.notEqual((await f.request("DELETE", collisionPath)).status, 204); - assert.equal((await f.request("GET", collisionPath)).data.state, "deleting"); - assert.ok([...f.providers.values()].includes(foreign)); - assert.deepEqual([...f.profiles], profilesBefore); + for (const baseUrl of ["https://**/v1", "https://[2001:db8::1]/v1"]) { + const collision = await f.seedInvalid("openai", baseUrl); + const existing = [...f.providers.values()][0]; + const foreign = { + ...existing, + name: openShellProviderName(collision.id), + labels: { ...existing.labels, "openclaw.dev/credential-source-id": collision.id }, + }; + f.providers.set(foreign.workspace + "/" + foreign.name, foreign); + const collisionPath = f.path + "/" + collision.id; + assert.equal((await f.request("GET", collisionPath)).data.status.state, "failed"); + assert.notEqual((await f.request("DELETE", collisionPath)).status, 204); + assert.equal((await f.request("GET", collisionPath)).data.state, "deleting"); + assert.ok([...f.providers.values()].includes(foreign)); + assert.deepEqual([...f.profiles], profilesBefore); + } const unknown = await f.seedInvalid("unknown"); assert.notEqual((await f.request("DELETE", f.path + "/" + unknown.id)).status, 204); @@ -264,3 +294,32 @@ test("OpenShell custom and default credential profiles keep independent ownershi assert.equal(f.profiles.size, 0); assert.equal(f.providers.size, 0); }); + +test("concrete credential source endpoints retain normalized profile scope", async (t) => { + const f = await fixture(t); + const cases = [ + ["https://models.example.test/v1", "models.example.test", 443, "/v1/**"], + ["https://MODELS.example.test:443/api/./v1/", "models.example.test", 443, "/api/v1/**"], + ["https://bücher.example.test:8443/api/v1/", "xn--bcher-kva.example.test", 8443, "/api/v1/**"], + ["https://192.0.2.1/v1", "192.0.2.1", 443, "/v1/**"], + ["https://API.OpenAI.com:443/v1/", "api.openai.com", 443, "/v1/**"], + ]; + for (const [base_url, host, port, path] of cases) { + const created = await f.create("concrete endpoint", { base_url }); + assert.equal(created.status, 201, JSON.stringify(created.body)); + // Assert the profile produced through HTTP/OCC, not a copied normalizer. + const profile = [...f.profiles.values()][0]; + assert.deepEqual(profile.endpoints, [{ host, port, protocol: "rest", path }]); + if (host === "api.openai.com") { + assert.equal(profile.id, "oce-openai"); + } + assert.equal( + (await f.request("GET", f.path + "/" + created.data.id)).data.status.state, + "ready", + ); + f.passRegistrationFence(); + assert.equal((await f.request("DELETE", f.path + "/" + created.data.id)).status, 204); + assert.equal(f.profiles.size, 0); + assert.equal(f.providers.size, 0); + } +}); From 90268463bbd255c3c13a0e0bdeb437728189eb1a Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:54:53 +0000 Subject: [PATCH 4/5] fix(compute): project Codex subagent model selections Qualify explicit default and per-agent child selections through the existing endpoint projection while preserving aliases, fallback references, native IDs, and admitted input. Rename the runtime endpoint option to codexModelBaseUrl and cover the real packaged spawn-to-native request boundary. Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> --- .../compute/codex-model-configuration.ts | 25 ++- .../src/drivers/compute/kubernetes/index.ts | 11 +- docs/flows/harness-execution-topology.md | 42 ++--- docs/reference/drivers/kubernetes-compute.md | 16 +- docs/testing/kubernetes.md | 2 +- .../test-suites/images-runtime-startup.json | 3 +- tests/conformance/kubernetes-compute.test.mjs | 77 ++++++-- .../fixtures/runtime-codex-subagent-model.mjs | 174 ++++++++++++++++++ tests/helpers/harness-topology-k3d-real.mjs | 2 +- .../runtime-image-startup.test.mjs | 121 +++++++++++- 10 files changed, 411 insertions(+), 62 deletions(-) create mode 100644 tests/fixtures/runtime-codex-subagent-model.mjs diff --git a/apps/controller/src/drivers/compute/codex-model-configuration.ts b/apps/controller/src/drivers/compute/codex-model-configuration.ts index baebfefff5..57ebf03e81 100644 --- a/apps/controller/src/drivers/compute/codex-model-configuration.ts +++ b/apps/controller/src/drivers/compute/codex-model-configuration.ts @@ -38,6 +38,10 @@ export function codexGatewayModelConfiguration( return `codex/${endpoint.modelProvider}/${id}`; }; const select = (reference: string): string => { + // Subagent aliases remain native aliases, even when named like the source provider. + if (!reference.includes("/")) { + return reference; + } selections.add(reference); return qualify(reference); }; @@ -45,15 +49,20 @@ export function codexGatewayModelConfiguration( if (settings === undefined) { continue; } - if (typeof settings.model === "string") { - settings.model = select(settings.model); - } else { - const selection = asRecord(settings.model); - if (typeof selection?.primary === "string") { - selection.primary = select(selection.primary); + for (const scope of [settings, asRecord(settings.subagents)]) { + if (scope === undefined) { + continue; } - if (Array.isArray(selection?.fallbacks)) { - selection.fallbacks = selection.fallbacks.map((reference) => select(reference)); + if (typeof scope.model === "string") { + scope.model = select(scope.model); + } else { + const selection = asRecord(scope.model); + if (typeof selection?.primary === "string") { + selection.primary = select(selection.primary); + } + if (Array.isArray(selection?.fallbacks)) { + selection.fallbacks = selection.fallbacks.map((reference) => select(reference)); + } } } const policies = asRecord(settings.models); diff --git a/apps/controller/src/drivers/compute/kubernetes/index.ts b/apps/controller/src/drivers/compute/kubernetes/index.ts index e807b6476c..7bbf783e4e 100644 --- a/apps/controller/src/drivers/compute/kubernetes/index.ts +++ b/apps/controller/src/drivers/compute/kubernetes/index.ts @@ -382,7 +382,8 @@ export interface KubernetesComputeDriverOptions { readonly nodeSelector?: Readonly>; readonly gatewayNodeSelector?: Readonly>; readonly codexSeccompProfile?: string; - readonly codexOpenaiBaseUrl?: string; + /** HTTPS OpenAI-compatible Responses endpoint for dedicated Codex API-key auth. */ + readonly codexModelBaseUrl?: string; readonly channels?: { readonly proxyUrl: string; readonly managedProxy?: KubernetesWorkloadPeer & { @@ -2133,7 +2134,7 @@ export class KubernetesComputeDriver implements ComputeDriver { nodeSelector: { type: "object", additionalProperties: { type: "string" } }, gatewayNodeSelector: { type: "object", additionalProperties: { type: "string" } }, codexSeccompProfile: { type: "string", minLength: 1 }, - codexOpenaiBaseUrl: { type: "string", minLength: 1 }, + codexModelBaseUrl: { type: "string", minLength: 1 }, channels: { type: "object", required: ["proxyUrl"], @@ -2386,8 +2387,8 @@ export class KubernetesComputeDriver implements ComputeDriver { ); } if ( - options.runtime.codexOpenaiBaseUrl !== undefined && - normalizeOpenAiBaseUrl(options.runtime.codexOpenaiBaseUrl) === undefined + options.runtime.codexModelBaseUrl !== undefined && + normalizeOpenAiBaseUrl(options.runtime.codexModelBaseUrl) === undefined ) { throw new ConfigurationFailure( "Codex model endpoint requires HTTPS, a nonzero port, and a path ending in /v1 without credentials, wildcards, query, or fragment.", @@ -10412,7 +10413,7 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`; revision.harness.mode === "dedicated" && revision.harnessAuth?.method === "api_key" ) { - openaiBaseUrl = this.options.runtime?.codexOpenaiBaseUrl; + openaiBaseUrl = this.options.runtime?.codexModelBaseUrl; } if (openaiBaseUrl !== undefined) { const normalized = normalizeOpenAiBaseUrl(openaiBaseUrl); diff --git a/docs/flows/harness-execution-topology.md b/docs/flows/harness-execution-topology.md index 5e9c42b9c2..289a4d59e8 100644 --- a/docs/flows/harness-execution-topology.md +++ b/docs/flows/harness-execution-topology.md @@ -1,7 +1,7 @@ --- created: 2026-08-21 -updated: 2026-10-08 -last_updated_session: authoring-run/02228d02-e16c-4a55-9a43-16b9efb35ebe +updated: 2026-10-09 +last_updated_session: agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 --- # Harness Execution Topology Flow @@ -171,27 +171,25 @@ its stop after one claim lease, then two, four, and so on. Failed preparation or preparing/activating that predecessor clears its stop record. A newer exclusive revision supersedes old reconciliation and maintenance, with no automatic rollback; see [production revision stages](../reference/drivers/compute.md#production-revision-stages). -For dedicated Codex API-key authentication, Kubernetes Compute selects the -bound credential source's endpoint first, then its optional -`runtime.codexOpenaiBaseUrl`. `pluginRuntimeSnapshot` in -`apps/controller/src/drivers/compute/kubernetes/index.ts` passes that endpoint to -`pluginRuntimeConfigMapData` in `apps/controller/src/drivers/compute/plugin-runtime.ts`. -The generated Codex config and manifest select HTTPS Responses streaming for an -explicit compatible endpoint. `probeCodexAuthentication` in -`apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts` carries -that endpoint through CLI overrides because its probe ignores user configuration. -Account login modes and other Harnesses retain their existing endpoints; model -credentials remain exclusively in the Harness Pod. -Compute normalizes both endpoint sources with -`apps/controller/src/drivers/openai-endpoint.ts:normalizeOpenAiBaseUrl`. +For dedicated Codex API-key auth, `pluginRuntimeSnapshot` in +`apps/controller/src/drivers/compute/kubernetes/index.ts` selects the bound source +endpoint before `runtime.codexModelBaseUrl`. +`apps/controller/src/drivers/openai-endpoint.ts:normalizeOpenAiBaseUrl` normalizes both sources. +`pluginRuntimeConfigMapData` in `apps/controller/src/drivers/compute/plugin-runtime.ts` +writes the HTTPS Responses endpoint/provider pair to the manifest and `config.toml`. +`apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts:probeCodexAuthentication` +passes it through CLI overrides because the probe ignores user configuration. +Account login modes and other Harnesses keep their endpoints; only the Harness holds model credentials. + For custom endpoints, `gatewayConfigurationDocument` calls `apps/controller/src/drivers/compute/codex-model-configuration.ts:codexGatewayModelConfiguration` -to clone the admitted document and qualify model references, policy keys, and -catalog IDs in the private Gateway ConfigMap. Full refs match declared selections; -native IDs, including a `codex/` prefix, remain intact. The manifest and -`config.toml` carry the endpoint/provider pair. OpenClaw separates the explicit -provider once for thread start, resume, and turns; the Harness probe keeps the -native ID. The admitted Configuration is unchanged. +to clone the admitted document. It qualifies parent and `subagents.model` selectors +under defaults and Agent entries, including primary/fallback objects, plus policy +keys and catalog IDs. Subagent-only selections enter the projected catalog; aliases +and omitted selectors retain their meaning. Full declared refs preserve native ID +namespaces, including `codex/`. OpenClaw separates the explicit provider for thread +start, resume, and turns; the Harness probe keeps the native ID. The admitted +Configuration remains unchanged. Dedicated Codex and dedicated OpenClaw must complete a bounded native authentication/model probe before their Harness becomes ready. @@ -314,6 +312,8 @@ owns claim sizes, mount paths, StorageClass requirements, and final teardown. ## Changelog +- 2026-10-09 17:35: Qualify explicit subagent selectors; rename the Codex endpoint option. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - f9126efaada11f8cc18931aa8327f7e16cae59b6) + - 2026-10-08 04:40: Reconciled dedicated endpoint flow with current native callback routing. (authoring-run/02228d02-e16c-4a55-9a43-16b9efb35ebe - 31b1b6a9ab59f219d0fbe3d44b1550f8c8f2fe4a) - 2026-10-07 12:07: Unify imported and managed PAT authentication while preserving source ownership and existing OAuth behavior. (01a0e5ec-d802-7800-9eb6-8022c1ac0d06 - be5006e62) diff --git a/docs/reference/drivers/kubernetes-compute.md b/docs/reference/drivers/kubernetes-compute.md index ac0b0e3243..844fdcf30b 100644 --- a/docs/reference/drivers/kubernetes-compute.md +++ b/docs/reference/drivers/kubernetes-compute.md @@ -188,14 +188,14 @@ in one AgentRevision Sandbox. Stopping a hosted session releases its slot; saved history does not consume capacity. OpenClaw does not yet retire idle paired-node workers, so size the limit against the Agent workload's CPU and memory limits. -For dedicated Codex `api_key` authentication, `runtime.codexOpenaiBaseUrl` selects -an HTTPS streaming Responses endpoint ending in `/v1` (including `/api/v1`). -Hostname/path wildcards, port `0`, credentials, queries, and fragments are rejected. Omission retains -OpenAI's default; bound OpenShell sources select their own endpoint. OAuth, -account logins, and OpenClaw ignore this option. Only the Harness receives the -model key. Private Gateway configuration qualifies native IDs without changing -their namespaces or admitted Configuration; its provider uses authenticated -app-server transport and fail-closed HTTP. See [Harness execution](../harness-execution.md). +For dedicated Codex `api_key` authentication, `runtime.codexModelBaseUrl` selects +an HTTPS OpenAI-compatible streaming Responses endpoint ending in `/v1` (including `/api/v1`). +Rejects hostname/path wildcards, port `0`, credentials, queries, and fragments. +Omission retains OpenAI's default; OpenShell sources select their own endpoint. +OAuth, account logins, and OpenClaw ignore this option. Only the Harness receives +the key. Private Gateway configuration qualifies parent and subagent selections +(strings or primary/fallback objects), preserving native IDs and admitted values. +Transport uses the authenticated app server and fail-closed HTTP. See [Harness execution](../harness-execution.md). ### Authentication diff --git a/docs/testing/kubernetes.md b/docs/testing/kubernetes.md index bd0c8894d3..2e0f0af1b2 100644 --- a/docs/testing/kubernetes.md +++ b/docs/testing/kubernetes.md @@ -374,7 +374,7 @@ For dedicated Codex coverage, a model override must support Codex custom tools. | `OCC_TEST_DATABASE_URL` | Migrated disposable loopback database named `openclaw_k8s_*`; the ordinary development database fails. | | `OPENAI_API_KEY` | Existing authorized provider credential for real embedded and dedicated model turns. | | `OCC_TEST_OPENAI_MODEL` | Authorized provider model; defaults to `gpt-6-astra`. | -| `OCC_TEST_CODEX_OPENAI_BASE_URL` | Optional HTTPS Responses endpoint for dedicated Codex API-key tests; applied through `runtime.codexOpenaiBaseUrl`. Keep the native Codex provider at its fail-closed loopback URL. | +| `OCC_TEST_CODEX_OPENAI_BASE_URL` | Optional HTTPS Responses endpoint for dedicated Codex API-key tests; applied through `runtime.codexModelBaseUrl`. Keep the native Codex provider at its fail-closed loopback URL. | The separate [`harness-topology-k3d-routing-real.test.mjs`](../../tests/integration/harness-topology-k3d-routing-real.test.mjs) diff --git a/scripts/ci/test-suites/images-runtime-startup.json b/scripts/ci/test-suites/images-runtime-startup.json index 6b799d0924..aa4aa0525e 100644 --- a/scripts/ci/test-suites/images-runtime-startup.json +++ b/scripts/ci/test-suites/images-runtime-startup.json @@ -21,7 +21,8 @@ "runtime image publishes dedicated assets with the Kubernetes gateway entrypoint", "runtime image tells OpenClaw that a read-only Kubernetes configuration is externally managed", "runtime image Gateway hot-loads its workspace node binding without restarting OpenClaw", - "runtime image validates the configuration dedicated native OpenClaw renders" + "runtime image validates the configuration dedicated native OpenClaw renders", + "runtime image spawns custom-provider subagents with intact native model IDs" ] }, { diff --git a/tests/conformance/kubernetes-compute.test.mjs b/tests/conformance/kubernetes-compute.test.mjs index 6b933d43b9..050d4d1bfd 100644 --- a/tests/conformance/kubernetes-compute.test.mjs +++ b/tests/conformance/kubernetes-compute.test.mjs @@ -28,6 +28,7 @@ import { ActivationPendingError, ComputeGatewaySettingError, ConfigurationHarnessError, + resolveConfiguredHarnessId, } from "../../packages/occ/src/index.ts"; import { currentComputeAbortSignal, @@ -913,7 +914,7 @@ function dedicatedFirstDeployFixture({ statusProxy = true, clock, modelEndpoint runtime: { transportSecretPrefix: "transport", gatewayStorageClassName: "local-path", - ...(modelEndpoint === undefined ? {} : { codexOpenaiBaseUrl: modelEndpoint }), + ...(modelEndpoint === undefined ? {} : { codexModelBaseUrl: modelEndpoint }), }, // The API server proxy can reach private status, so activation reads the // node OpenClaw applied. @@ -1271,11 +1272,14 @@ test("custom Codex endpoints compile explicit providers without changing the adm const primary = `${sourceProvider}/vendor/organization/model`; const fallback = `${sourceProvider}/codex/native`; const plain = `${sourceProvider}/plain-model`; + const child = `${sourceProvider}/vendor/organization/child`; + const childFallback = `${sourceProvider}/codex/child-fallback`; + const childSelection = { primary: child, fallbacks: [childFallback, "primary"] }; const settings = { model: { primary, fallbacks: [fallback, plain] }, models: { [primary]: { agentRuntime: { id: "codex" }, alias: "primary" }, - [fallback]: { agentRuntime: { id: "codex" } }, + [fallback]: { agentRuntime: { id: "codex" }, alias: sourceProvider }, [plain]: { agentRuntime: { id: "codex" } }, [`${sourceProvider}/*`]: { agentRuntime: { id: "codex" } }, }, @@ -1283,12 +1287,27 @@ test("custom Codex endpoints compile explicit providers without changing the adm fixture.revision.configuration = admitLoggingConfiguration( { ...fixture.revision.configuration, + // OpenAI-prefixed native selections require the explicit Codex WebSocket bridge. + plugins: createHarnessConfiguration("codex", "vendor/organization/model").plugins, agents: { defaults: { ...structuredClone(settings), model: sourceProvider === "openai" ? primary : structuredClone(settings.model), + subagents: { + model: sourceProvider === "codex" ? child : structuredClone(childSelection), + }, + }, + entries: { + main: { + ...structuredClone(settings), + subagents: { + model: sourceProvider === "codex" ? structuredClone(childSelection) : child, + }, + }, + alias: { subagents: { model: "primary" } }, + providerAlias: { subagents: { model: sourceProvider } }, + inherited: {}, }, - entries: { main: structuredClone(settings) }, }, models: { providers: { @@ -1309,6 +1328,7 @@ test("custom Codex endpoints compile explicit providers without changing the adm "info", ); const admitted = structuredClone(fixture.revision.configuration); + assert.equal(resolveConfiguredHarnessId(admitted), "codex"); await fixture.prepare(); const snapshot = [...fixture.objects.values()].find( (object) => object.kind === "ConfigMap" && object.data?.["openclaw.json"] !== undefined, @@ -1325,6 +1345,22 @@ test("custom Codex endpoints compile explicit providers without changing the adm sourceProvider === "openai" ? expectedSelection.primary : expectedSelection, ); assert.deepEqual(rendered.agents.entries.main.model, expectedSelection); + const expectedChild = `${qualified}vendor/organization/child`; + const expectedChildSelection = { + primary: expectedChild, + fallbacks: [`${qualified}codex/child-fallback`, "primary"], + }; + assert.deepEqual( + rendered.agents.defaults.subagents.model, + sourceProvider === "codex" ? expectedChild : expectedChildSelection, + ); + assert.deepEqual( + rendered.agents.entries.main.subagents.model, + sourceProvider === "codex" ? expectedChildSelection : expectedChild, + ); + assert.equal(rendered.agents.entries.alias.subagents.model, "primary"); + assert.equal(rendered.agents.entries.providerAlias.subagents.model, sourceProvider); + assert.equal(rendered.agents.entries.inherited.subagents, undefined); for (const actual of [rendered.agents.defaults, rendered.agents.entries.main]) { assert.deepEqual(Object.keys(actual.models), [ `${qualified}vendor/organization/model`, @@ -1334,15 +1370,24 @@ test("custom Codex endpoints compile explicit providers without changing the adm ]); assert.equal(actual.models[`${qualified}vendor/organization/model`].alias, "primary"); } - assert.deepEqual(rendered.models.providers.codex.models, [ - { - id: "openai-compatible/vendor/organization/model", - name: "Primary model", - contextWindow: 32000, - }, - { id: "openai-compatible/codex/native", name: "Fallback model" }, - { id: "openai-compatible/plain-model", name: "Plain model" }, - ]); + const byModelId = (left, right) => left.id.localeCompare(right.id); + assert.deepEqual( + rendered.models.providers.codex.models.toSorted(byModelId), + [ + { + id: "openai-compatible/vendor/organization/model", + name: "Primary model", + contextWindow: 32000, + }, + { id: "openai-compatible/codex/native", name: "Fallback model" }, + { id: "openai-compatible/plain-model", name: "Plain model" }, + { + id: "openai-compatible/vendor/organization/child", + name: "vendor/organization/child", + }, + { id: "openai-compatible/codex/child-fallback", name: "codex/child-fallback" }, + ].toSorted(byModelId), + ); assert.deepEqual(fixture.revision.configuration, admitted); const runtime = [...fixture.objects.values()].find( (object) => object.kind === "ConfigMap" && object.data?.["runtime.json"] !== undefined, @@ -4307,7 +4352,7 @@ test("the canonical Kubernetes runtime validates channel proxy configuration", ( test("the canonical Kubernetes runtime validates the dedicated Codex model endpoint", () => { const runtime = { transportSecretPrefix: "transport", gatewayStorageClassName: "local-path" }; - for (const codexOpenaiBaseUrl of [ + for (const codexModelBaseUrl of [ "https://models.example.test/v1", "https://models.example.test/api/v1/", "https://API.OpenAI.com:443/v1/", @@ -4321,10 +4366,10 @@ test("the canonical Kubernetes runtime validates the dedicated Codex model endpo "https://models.example.test./v1", ]) { assert.doesNotThrow(() => - createKubernetesComputeDriver(options({ runtime: { ...runtime, codexOpenaiBaseUrl } })), + createKubernetesComputeDriver(options({ runtime: { ...runtime, codexModelBaseUrl } })), ); } - for (const codexOpenaiBaseUrl of [ + for (const codexModelBaseUrl of [ "not-a-url", "http://models.example.test/v1", syntheticCredentialUrl({ @@ -4348,7 +4393,7 @@ test("the canonical Kubernetes runtime validates the dedicated Codex model endpo "https://models.example.test/v1#fragment", ]) { assert.throws( - () => createKubernetesComputeDriver(options({ runtime: { ...runtime, codexOpenaiBaseUrl } })), + () => createKubernetesComputeDriver(options({ runtime: { ...runtime, codexModelBaseUrl } })), /Codex model endpoint/, ); } diff --git a/tests/fixtures/runtime-codex-subagent-model.mjs b/tests/fixtures/runtime-codex-subagent-model.mjs new file mode 100644 index 0000000000..fceef147db --- /dev/null +++ b/tests/fixtures/runtime-codex-subagent-model.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; +import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; + +const require = createRequire("/app/package.json"); +const { WebSocketServer } = require("ws"); +const cases = JSON.parse(process.env.OCC_TEST_SUBAGENT_CASES); +const nativeModels = JSON.parse(process.env.OCC_TEST_SUBAGENT_MODELS); +const provenance = JSON.parse(await readFile("/opt/oce/runtime/provenance.json", "utf8")); +assert.equal(provenance.commit, process.env.OCC_TEST_OPENCLAW_COMMIT); + +// Only the external Codex peer is a fixture. The packaged Gateway performs real +// sessions_spawn admission, model preparation, child dispatch and native model resolution. +// The peer stops at thread/start: no Codex process, model inference or external egress. +const requests = []; +let unsupportedMethod; +const server = new WebSocketServer({ host: "127.0.0.1", port: 4500 }); +await new Promise((resolve, reject) => { + server.once("listening", resolve); + server.once("error", reject); +}); +server.on("connection", (socket, request) => { + assert.equal(request.headers.authorization, `Bearer ${process.env.APP_SERVER_TOKEN}`); + socket.on("message", (bytes) => { + const message = JSON.parse(bytes.toString()); + if (message.id === undefined) { + return; + } + requests.push(message); + const reply = (result) => socket.send(JSON.stringify({ id: message.id, result })); + switch (message.method) { + case "initialize": + reply({ userAgent: "codex-cli/0.160.0", codexHome: "/home/node/.codex" }); + break; + case "account/read": + reply({ account: { type: "apiKey" }, requiresOpenaiAuth: true }); + break; + case "config/read": + reply({ config: {}, origins: {}, layers: [] }); + break; + case "configRequirements/read": + reply({ requirements: null }); + break; + case "model/list": + reply({ + data: nativeModels.map((id) => ({ + id, + model: id, + displayName: id, + description: "Synthetic native routing fixture", + hidden: false, + isDefault: false, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + inputModalities: ["text"], + })), + nextCursor: null, + }); + break; + case "skills/list": + case "thread/list": + reply({ data: [], nextCursor: null }); + break; + case "thread/start": + socket.send( + JSON.stringify({ + id: message.id, + error: { code: -32602, message: "Routing fixture stops before native inference." }, + }), + ); + break; + default: + unsupportedMethod = message.method; + socket.send( + JSON.stringify({ + id: message.id, + error: { code: -32601, message: `Unsupported fixture method: ${message.method}` }, + }), + ); + } + }); +}); + +async function waitFor(description, read) { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + assert.equal( + unsupportedMethod, + undefined, + "The native peer requested an unsupported fixture method.", + ); + const value = await read(); + if (value) { + return value; + } + await delay(100); + } + throw new Error( + `Timed out waiting for ${description}; methods=${requests.map(({ method }) => method).join(",")}`, + ); +} + +try { + await waitFor("Gateway readiness", async () => { + try { + return (await fetch("http://127.0.0.1:8080/readyz")).ok; + } catch { + return false; + } + }); + const observed = []; + for (const scenario of cases) { + const parentKey = `agent:${scenario.agentId}:routing-parent`; + await callGatewayFromCli( + "sessions.create", + { + url: "ws://127.0.0.1:8080", + password: process.env.OPENCLAW_GATEWAY_PASSWORD, + timeout: "90000", + }, + { key: parentKey, agentId: scenario.agentId }, + ); + const offset = requests.length; + const response = await fetch("http://127.0.0.1:8080/tools/invoke", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.OPENCLAW_GATEWAY_PASSWORD}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + tool: "sessions_spawn", + sessionKey: parentKey, + args: { + agentId: scenario.agentId, + task: "Verify native model routing without running any tools.", + mode: "run", + context: "isolated", + runTimeoutSeconds: 30, + }, + }), + }); + const result = await response.json(); + assert.equal(response.status, 200, JSON.stringify(result)); + assert.equal(result.ok, true, JSON.stringify(result)); + const receipt = result.result.details; + assert.equal(receipt.status, "accepted", JSON.stringify(result)); + const native = await waitFor("native child thread/start", () => + requests + .slice(offset) + .find( + ({ method, params }) => + method === "thread/start" && params.cwd === `/home/node/workspace/${scenario.agentId}`, + ), + ); + assert.deepEqual( + { modelProvider: native.params.modelProvider, model: native.params.model }, + { modelProvider: "openai-compatible", model: scenario.model }, + ); + observed.push({ + agentId: scenario.agentId, + childSessionKey: receipt.childSessionKey, + modelProvider: native.params.modelProvider, + model: native.params.model, + }); + } + console.log(JSON.stringify({ commit: provenance.commit, observed })); +} finally { + for (const socket of server.clients) { + socket.terminate(); + } + await new Promise((resolve) => server.close(resolve)); +} diff --git a/tests/helpers/harness-topology-k3d-real.mjs b/tests/helpers/harness-topology-k3d-real.mjs index 90abade32e..53b9a9f0fe 100644 --- a/tests/helpers/harness-topology-k3d-real.mjs +++ b/tests/helpers/harness-topology-k3d-real.mjs @@ -900,7 +900,7 @@ function installationConfiguration(authentication, platformNamespace, slack, opt configuration.drivers.configuration.id = "configuration-kubernetes-production"; configuration.drivers.compute.id = "compute-kubernetes-production"; if (process.env.OCC_TEST_CODEX_OPENAI_BASE_URL !== undefined) { - configuration.drivers.compute.configuration.runtime.codexOpenaiBaseUrl = + configuration.drivers.compute.configuration.runtime.codexModelBaseUrl = process.env.OCC_TEST_CODEX_OPENAI_BASE_URL; } configuration.drivers.plugin = { id: "codex-plugin", configuration: {} }; diff --git a/tests/integration/runtime-image-startup.test.mjs b/tests/integration/runtime-image-startup.test.mjs index e63c187cb1..132a02e85e 100644 --- a/tests/integration/runtime-image-startup.test.mjs +++ b/tests/integration/runtime-image-startup.test.mjs @@ -31,7 +31,13 @@ import { REPOSITORY_MATERIAL_INIT_ENTRYPOINT, REPOSITORY_NATIVE_GIT_INIT_ENTRYPOINT, } from "../../apps/controller/src/drivers/compute/kubernetes/repository-material-init.ts"; -import { PINNED_OPENCLAW_RUNTIME_SUPPORTS_NATIVE_WORKERS } from "../../packages/occ/src/index.ts"; +import { + PINNED_OPENCLAW_RUNTIME_SUPPORTS_NATIVE_WORKERS, + resolveConfiguredHarnessId, +} from "../../packages/occ/src/index.ts"; +import { admitLoggingConfiguration } from "../../packages/contracts/src/index.ts"; +import { codexGatewayModelConfiguration } from "../../apps/controller/src/drivers/compute/codex-model-configuration.ts"; +import { createHarnessConfiguration } from "../helpers/harness-configuration.mjs"; import { createNativeClientMaterial } from "../fixtures/repository-credentials/clients.mjs"; import { startRegistryCredentialServiceFixture } from "../fixtures/repository-credentials/registry.mjs"; import { codexOpenClawConfiguration } from "../../apps/controller/src/drivers/plugin/runtime-translator.ts"; @@ -2097,3 +2103,116 @@ process.stdout.write("shared-codex-0.160.0-ready\n"); assert.match(stdout, /shared-codex-0.160.0-ready/); }, ); + +test( + "runtime image spawns custom-provider subagents with intact native model IDs", + imageTestOptions, + async (t) => { + const nativeIds = { + parent: "vendor/organization/parent", + main: "vendor/organization/default-child", + string: "vendor/organization/string-child", + object: "vendor/organization/object-child", + fallback: "codex/child-fallback", + }; + const ref = (name) => `codex/${nativeIds[name]}`; + const source = createHarnessConfiguration("codex", nativeIds.parent); + source.agents.ownership = "explicit"; + source.agents.defaults.systemAgent = { agentId: "main" }; + source.agents.defaults.models = Object.fromEntries( + Object.keys(nativeIds).map((name) => [ + ref(name), + { + agentRuntime: { id: "codex" }, + ...(name === "parent" ? { alias: "codex" } : {}), + }, + ]), + ); + source.agents.defaults.subagents = { model: ref("main") }; + source.agents.entries = { + main: {}, + string: { subagents: { model: ref("string") } }, + object: { subagents: { model: { primary: ref("object"), fallbacks: [ref("fallback")] } } }, + alias: { subagents: { model: "codex" } }, + }; + for (const [id, entry] of Object.entries(source.agents.entries)) { + entry.workspace = `/home/node/workspace/${id}`; + } + source.gateway.tools = { allow: ["sessions_spawn"] }; + source.tools = { + codeMode: false, + toolSearch: false, + allow: ["sessions_spawn"], + }; + const admitted = admitLoggingConfiguration(source, "info"); + assert.equal(resolveConfiguredHarnessId(admitted), "codex"); + const before = structuredClone(admitted); + const configuration = codexGatewayModelConfiguration(admitted, { + baseUrl: "https://models.example.test/v1", + modelProvider: "openai-compatible", + }); + assert.deepEqual(admitted, before); + + // Keep the real packaged Gateway and Codex plugin; replace only the native peer + // at its authenticated WebSocket boundary. The network-none container cannot + // call a provider, and the peer deliberately refuses before any model turn. + const { containerName } = await runGatewaySmoke(t, "codex", { + configuration, + collectPlugins: false, + waitUntilReady: false, + extraEnvironment: ["APP_SERVER_URL=ws://127.0.0.1:4500", "OPENCLAW_DEBUG=1"], + }); + const fixture = await readFile( + new URL("../fixtures/runtime-codex-subagent-model.mjs", import.meta.url), + "utf8", + ); + const dockerfile = await readFile( + new URL("../../deploy/runtime/Dockerfile", import.meta.url), + "utf8", + ); + const commit = dockerfile.match(/^ARG OPENCLAW_COMMIT=([0-9a-f]{40})$/m)?.[1]; + assert.ok(commit, "The image source pin must be explicit."); + const cases = ["main", "string", "object", "alias"].map((agentId) => ({ + agentId, + model: nativeIds[agentId === "alias" ? "parent" : agentId], + })); + let stdout; + try { + ({ stdout } = await runDocker( + [ + "exec", + "-e", + `OCC_TEST_SUBAGENT_CASES=${JSON.stringify(cases)}`, + "-e", + `OCC_TEST_SUBAGENT_MODELS=${JSON.stringify(Object.values(nativeIds))}`, + "-e", + `OCC_TEST_OPENCLAW_COMMIT=${commit}`, + containerName, + "node", + "--input-type=module", + "-e", + fixture, + ], + { timeout: 480_000 * imageSmokeTimeoutMultiplier }, + )); + } catch (error) { + const logs = await runDocker(["logs", containerName]).catch((cause) => cause); + t.diagnostic(commandOutput(logs)); + throw error; + } + const result = JSON.parse(stdout.trim().split("\n").at(-1)); + t.diagnostic(JSON.stringify(result)); + assert.equal(result.commit, commit); + assert.deepEqual( + result.observed.map(({ agentId, modelProvider, model }) => ({ + agentId, + modelProvider, + model, + })), + cases.map((scenario) => ({ ...scenario, modelProvider: "openai-compatible" })), + ); + assert.ok( + result.observed.every(({ childSessionKey }) => childSessionKey.includes(":subagent:")), + ); + }, +); From a57cde8b7d55e0ec67d2ae6c1d7f0b797bc9f6ac Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Tue, 13 Oct 2026 20:15:20 +0000 Subject: [PATCH 5/5] fix(openshell): validate endpoint hosts and source ownership --- .../drivers/credential-gateway/openshell.ts | 27 ++- docs/flows/credential-source-lifecycle.md | 28 +-- .../drivers/openshell-credential-gateway.md | 10 +- .../credential-source-api.test.mjs | 6 + .../sandbox-driver-startup.test.mjs | 229 +++++++++++++++++- 5 files changed, 269 insertions(+), 31 deletions(-) diff --git a/apps/controller/src/drivers/credential-gateway/openshell.ts b/apps/controller/src/drivers/credential-gateway/openshell.ts index bbc088a964..fb0bee5f35 100644 --- a/apps/controller/src/drivers/credential-gateway/openshell.ts +++ b/apps/controller/src/drivers/credential-gateway/openshell.ts @@ -61,7 +61,7 @@ const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1"; const OPENAI_BASE_URL_CONFIG = "base_url"; const SOURCE_ENDPOINT_REQUIREMENTS = - "The OpenShell endpoint requires HTTPS, a nonzero port, and a /v1 path. Wildcards, bracketed IPv6 hosts, URL credentials, query, and fragment are unsupported."; + "The OpenShell endpoint requires HTTPS, a nonzero port, and a /v1 path. Wildcards, bracketed IPv6 hosts, empty hostname labels, braces, URL credentials, query, and fragment are unsupported."; /** How the paired Credential Refresh Driver configures one `refresh` type. */ export interface OpenShellRefreshSourceType { @@ -482,9 +482,18 @@ function ownedBy(provider: OpenShellProviderResponse, sourceId: string): boolean function normalizedSourceBaseUrl(config: Readonly>): string | undefined { const baseUrl = normalizeOpenAiBaseUrl(config[OPENAI_BASE_URL_CONFIG] ?? OPENAI_DEFAULT_BASE_URL); - // IPv6 brackets are URI syntax, but HostPattern interprets them as character classes. - // Do not glob-escape them without an exact representation shared by policy matching. - if (baseUrl === undefined || new URL(baseUrl).hostname.startsWith("[")) { + if (baseUrl === undefined) { + return undefined; + } + const hostname = new URL(baseUrl).hostname; + // Match OpenShell's HostPattern requirements before storing the source. + // IPv6 brackets are interpreted as pattern syntax, not literal URI brackets. + if ( + hostname.startsWith("[") || + hostname.includes("{") || + hostname.includes("}") || + hostname.split(".").some((label) => label.length === 0) + ) { return undefined; } return baseUrl; @@ -568,6 +577,15 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver const workspace = openShellWorkspaceName(context.namespace); const client = this.client(context); const credentials = providerCredentials(type, input); + const name = openShellProviderName(context.source.id); + const existing = await client.getProvider(workspace, name, context.signal); + if (existing !== undefined && !ownedBy(existing, context.source.id)) { + throw new ScopeViolationError( + "An OpenShell provider with this source's name is not owned by the source.", + ); + } + // Reject known collisions before changing the profile. These are separate RPCs: + // orphan profiles and provider replacement after this read remain outside this check. await this.ensureProfile( client, workspace, @@ -576,7 +594,6 @@ export class OpenShellCredentialGatewayDriver implements CredentialGatewayDriver input.config, context.signal, ); - const name = openShellProviderName(context.source.id); try { await client.createProvider( { diff --git a/docs/flows/credential-source-lifecycle.md b/docs/flows/credential-source-lifecycle.md index 160ded64f3..4e1ad0571a 100644 --- a/docs/flows/credential-source-lifecycle.md +++ b/docs/flows/credential-source-lifecycle.md @@ -1,7 +1,7 @@ --- created: "2026-09-26" -updated: 2026-10-12 -last_updated_session: 850fdf4a-7ef8-425c-9c44-942ac1aee05f +updated: 2026-10-13 +last_updated_session: authoring-run/01a136d4-3550-7042-834c-cf11f51f41f2 --- # Credential source lifecycle Flow @@ -73,13 +73,11 @@ It then calls the Driver's side-effect-free `validateSourceConfig`. Invalid values return `CredentialSourceConfigError` (`400 INVALID_REQUEST`) before any Secret read, record creation, or gateway write. The shared `apps/controller/src/drivers/openai-endpoint.ts:normalizeOpenAiBaseUrl` -parses URLs before rejecting wildcard hostnames (including decoded percent -escapes), wildcard paths, and port `0`. OpenShell additionally uses +rejects decoded hostname wildcards, wildcard paths, and port `0`. OpenShell's `apps/controller/src/drivers/credential-gateway/openshell.ts:normalizedSourceBaseUrl` -to reject bracketed IPv6 hosts: its profile matcher interprets brackets as -character classes, not literal URI syntax. Compute URL validation still accepts -IPv6. Registration and attachment require the OpenShell-safe representation; -status and deletion retain the no-guessed-owner behavior for invalid stored sources. +also rejects bracketed IPv6, braces, and empty hostname labels its matcher cannot +use. Compute still accepts IPv6. Registration and attachment require the +OpenShell-safe form; status and deletion do not guess ownership from an invalid URL. ### 2. Read Secret values @@ -99,12 +97,12 @@ labels, UID, and key before decoding it. A Driver without `withValue` returns `5 `packages/occ/src/index.ts:abandonCredentialRegistration`, `apps/controller/src/drivers/credential-gateway/openshell.ts:registerSource` -The transaction inserts a `registering` `credential_sources` row and -`credential_source_secrets` rows. The OpenShell provider name derives from the -source ID. Compute's `resolveSandboxNamespace` supplies the paired Sandbox's -placement. OCC calls `registerSource` with a 30-second timeout; the Driver ensures -the source-owned profile and creates an OCC-labeled provider with that -`profile_workspace`. Retries adopt only matching labels. +The transaction inserts `registering` source and Secret-reference rows. The +OpenShell provider name derives from the source ID. Compute's `resolveSandboxNamespace` +supplies the Sandbox placement. OCC calls `registerSource` with a 30-second timeout; the +Driver checks existing provider ownership, ensures the source's profile, and +creates the labeled provider. It rechecks ownership on a creation collision; the +gateway calls are not atomic. For terminal `failed` or `absent` results, `abandonCredentialRegistration` removes the provider and record, retaining a `deleting` record if removal fails. @@ -313,6 +311,8 @@ Driver detaches the provider again only if `SandboxSpec.providers` lists it. ## Changelog +- 2026-10-13 20:07: Check provider ownership before profile updates. (authoring-run/01a136d4-3550-7042-834c-cf11f51f41f2 - 146c80ef66b5860cb9bea296d2574156468a103c) + - 2026-10-12 14:00: Every source, including `openai`, owns its OpenShell profile; no profile is shared by type. (claude-code/session_014fi7Uq1LyofgqwLrLoQ3yY - e0bb26d84) - 2026-10-10 04:53: Reconcile credential refresh; report invalid offered-source values as field-specific 400 before effects. (agent:roboclaw:dashboard:9d0532e1-befb-4fc3-935e-7cd2a0c72110 - 90268463bbd255c3c13a0e0bdeb437728189eb1a) diff --git a/docs/reference/drivers/openshell-credential-gateway.md b/docs/reference/drivers/openshell-credential-gateway.md index 32334d9eaa..2ba6a1fb23 100644 --- a/docs/reference/drivers/openshell-credential-gateway.md +++ b/docs/reference/drivers/openshell-credential-gateway.md @@ -108,7 +108,7 @@ reach the gateway. | `oauth2-client-credentials` | `client_secret` (required) | `host`, `env_var`, `token_url`, `client_id` (required); `port`, `path`, `scope` | `refresh` | None (tool credential) | | `oauth2-refresh-token` | `refresh_token` (required); `client_secret` | `host`, `env_var`, `token_url`, `client_id` (required); `port`, `path`, `scope` | `refresh` | None (tool credential) | -`openai` defaults to `https://api.openai.com/v1`. Custom HTTPS endpoints may have a prefix such as `/api/v1` and must support Responses. Hostname and path wildcards, port `0`, URL credentials, queries, and fragments are rejected before Secret reads or persistence with `400 INVALID_REQUEST`. The Driver also rejects bracketed IPv6 hosts, which OpenShell treats as patterns. The endpoint is immutable; register a new source to change it. +`openai` defaults to `https://api.openai.com/v1`. Custom HTTPS endpoints may have a prefix such as `/api/v1` and must support Responses. Hostname and path wildcards, port `0`, URL credentials, queries, and fragments are rejected before Secret reads or persistence with `400 INVALID_REQUEST`. The Driver also rejects bracketed IPv6 hosts, braces, and empty hostname labels, which OpenShell cannot match. The endpoint is immutable; register a new source to change it. Invalid `bearer-token` and OAuth2 values return field-specific `400` before Secret reads or persistence: @@ -181,9 +181,11 @@ Workspace: - **One provider per source.** The name is `oce-cs-` followed by 24 hexadecimal characters of the SHA-256 digest of the source ID. Labels record OCC ownership, the source ID, and the Namespace ID. The provider's - `profile_workspace` names its own workspace, where OCC imported the profile. A - retried registration adopts an existing provider only when those labels match; - otherwise it fails. + `profile_workspace` names its own workspace, where OCC imported the profile. + Before changing the profile, registration rejects an existing provider whose + ownership labels or type do not match the source. It checks again if creation + finds an existing provider. These separate calls are not atomic; a provider or + orphan profile can change between them. - **Workspace.** OCC resolves the Namespace's workspace from Compute's runtime placement, so registration uses the same workspace as the paired Sandbox. diff --git a/tests/integration/credential-source-api.test.mjs b/tests/integration/credential-source-api.test.mjs index 3ab65c5459..e3080fec88 100644 --- a/tests/integration/credential-source-api.test.mjs +++ b/tests/integration/credential-source-api.test.mjs @@ -159,6 +159,12 @@ test("credential source HTTP admission rejects invalid endpoints without retaini "https://%2A.example.test/v1", "https://ex%2aample.test/v1", "https://*.example.test/v1", + // OpenShell's hostname matcher rejects empty labels and brace patterns. + "https://models.example.test./v1", + "https://models..example.test/v1", + "https://{a,b}.example.test/v1", + "https://{models}.example.test/v1", + "https://%7Bmodels%7D.example.test/v1", "https://models.example.test:0/v1", "https://models.example.test:000/v1", // URI-valid IPv6 brackets would be character classes in OpenShell host patterns. diff --git a/tests/integration/sandbox-driver-startup.test.mjs b/tests/integration/sandbox-driver-startup.test.mjs index d9ea139bb7..1b64c48b88 100644 --- a/tests/integration/sandbox-driver-startup.test.mjs +++ b/tests/integration/sandbox-driver-startup.test.mjs @@ -2159,6 +2159,10 @@ function credentialGatewayOverProviders({ keepDeleted = false, toolBinaries } = }, async importProviderProfile(_workspace, profile) { profileWrites.push(["import", profile.id]); + // Pinned OpenShell imports use MustCreate; they cannot overwrite a collision. + if (profiles.has(profile.id)) { + throw new Error("provider profile already exists"); + } profiles.set(profile.id, { ...profile, resourceVersion: String(++resourceVersion) }); }, // Like OpenShell, an update applies only over the version the writer last read. @@ -2247,8 +2251,17 @@ function credentialGatewayOverProviders({ keepDeleted = false, toolBinaries } = } test("the OpenShell Credential Gateway acts only on its own source's provider", async () => { - const { calls, context, driver, input, providers, revisionContext, source } = - credentialGatewayOverProviders(); + const { + calls, + context, + driver, + input, + profiles, + profileWrites, + providers, + revisionContext, + source, + } = credentialGatewayOverProviders(); const owner = "cs_00000000-0000-4000-8000-0000000000b1"; const other = "cs_00000000-0000-4000-8000-0000000000b2"; assert.deepEqual(await driver.registerSource(context(owner), input()), { state: "ready" }); @@ -2259,8 +2272,23 @@ test("the OpenShell Credential Gateway acts only on its own source's provider", ...stored, labels: { ...stored.labels, "openclaw.dev/credential-source-id": other }, }); + // The colliding profile differs from the requested endpoint, so reconciliation would write. + const foreignProfile = { + ...profiles.get(stored.name), + endpoints: [{ host: "foreign.example.test", port: 443, path: "/v1/**", protocol: "rest" }], + annotations: { "openclaw.dev/profile-digest": "foreign-digest" }, + }; + profiles.set(stored.name, structuredClone(foreignProfile)); calls.length = 0; - await assert.rejects(driver.registerSource(context(owner), input()), ScopeViolationError); + profileWrites.length = 0; + const requested = { ...input(), config: { base_url: "https://requested.example.test/v1" } }; + await assert.rejects(driver.registerSource(context(owner), requested), ScopeViolationError); + assert.deepEqual( + profiles.get(stored.name), + foreignProfile, + "foreign profile contents and version stay unchanged", + ); + assert.deepEqual(profileWrites, []); await assert.rejects( driver.updateSource(context(owner), input("replacement")), ScopeViolationError, @@ -2271,14 +2299,199 @@ test("the OpenShell Credential Gateway acts only on its own source's provider", driver.attachForRevision(revisionContext([source(owner)])), /provider for a bound credential source is unavailable/, ); - // Only the replayed create reached the store, and it was refused; nothing was overwritten. - assert.deepEqual( - calls.map(([operation]) => operation), - ["createProvider"], - ); + // Ownership refusal happens before any provider or profile mutation. + assert.deepEqual(calls, []); assert.deepEqual(providers.get(stored.name).credentials, stored.credentials); }); +test("OpenShell preserves the main-era owned profile lifecycle without a migration", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_main-era"); + const otherContext = f.context("cs_main-era-other"); + for (const owner of [context, otherContext]) { + await f.driver.registerSource(owner, f.input("original-key")); + } + // Main stored only the digest annotation. Keep genuine provider ownership and profile content. + for (const [id, profile] of f.profiles) { + f.profiles.set(id, { + ...profile, + annotations: { + "openclaw.dev/profile-digest": profile.annotations["openclaw.dev/profile-digest"], + }, + }); + } + const [provider, otherProvider] = f.providers.values(); + const original = structuredClone(f.profiles.get(provider.name)); + const otherProfile = structuredClone(f.profiles.get(otherProvider.name)); + f.profileWrites.length = 0; + const restarted = f.driverWith(); + assert.deepEqual(await restarted.sourceStatus(context), { state: "ready" }); + assert.deepEqual(await restarted.attachForRevision(f.revisionContext([context.source])), [ + { sourceId: context.source.id, ref: provider.name }, + ]); + assert.deepEqual(await restarted.updateSource(context, f.input("rotated-key")), { + state: "ready", + }); + assert.deepEqual(await f.driverWith().registerSource(context, f.input("replay-key")), { + state: "ready", + }); + assert.equal(f.providers.get(provider.name).credentials.OPENAI_API_KEY, "rotated-key"); + assert.deepEqual(f.profiles.get(provider.name), original); + assert.deepEqual(f.profileWrites, [], "unchanged main-era profiles need no marker-only write"); + + // A normal binary-policy change reconciles the owned profile using its resource version. + const narrowed = f.driverWith({ binaries: ["/opt/codex"] }); + await narrowed.attachForRevision(f.revisionContext([context.source])); + assert.deepEqual(f.profiles.get(provider.name).binaries, ["/opt/codex"]); + assert.notEqual(f.profiles.get(provider.name).resourceVersion, original.resourceVersion); + assert.deepEqual(f.profiles.get(otherProvider.name), otherProfile); + assert.deepEqual(f.providers.get(otherProvider.name), otherProvider); + await narrowed.removeSource(context); + assert.equal(f.providers.has(provider.name), false); + assert.equal(f.profiles.has(provider.name), false); + assert.deepEqual(f.profiles.get(otherProvider.name), otherProfile); + + // Deletion also works directly on an unmarked profile, without a prior reconciliation. + await f.driverWith().removeSource(otherContext); + await f.driverWith().removeSource(otherContext); + assert.equal(f.providers.size, 0); + assert.equal(f.profiles.size, 0); +}); + +test("OpenShell main-era interrupted registration survives restart without replaying credentials", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_owned-retry"); + const create = f.client.createProvider; + const unavailable = new Error("create provider unavailable"); + f.client.createProvider = async () => { + throw unavailable; + }; + // The import succeeded but provider creation did not. Main can resume without new annotations. + await assert.rejects( + f.driver.registerSource(context, f.input()), + (error) => error === unavailable, + ); + assert.equal(f.profiles.size, 1); + assert.equal(f.providers.size, 0); + for (const [id, profile] of f.profiles) { + f.profiles.set(id, { + ...profile, + annotations: { + "openclaw.dev/profile-digest": profile.annotations["openclaw.dev/profile-digest"], + }, + }); + } + f.client.createProvider = create; + f.profileWrites.length = 0; + const restarted = f.driverWith(); + assert.deepEqual(await restarted.registerSource(context, f.input("first-key")), { + state: "ready", + }); + assert.deepEqual(await restarted.registerSource(context, f.input("retry-key")), { + state: "ready", + }); + assert.deepEqual(f.profileWrites, []); + const [provider] = f.providers.values(); + assert.equal(provider.credentials.OPENAI_API_KEY, "first-key"); + // Owned reconciliation still applies new binary configuration through registration. + await f.driverWith({ binaries: ["/opt/codex"] }).registerSource(context, f.input("retry-key")); + assert.deepEqual(f.profiles.get(provider.name).binaries, ["/opt/codex"]); + assert.equal(f.providers.get(provider.name).credentials.OPENAI_API_KEY, "first-key"); + // Legacy cleanup can remove this orphan, and repeated deletion stays idempotent. + f.providers.delete(provider.name); + await restarted.removeSource(context); + await restarted.removeSource(context); + assert.equal(f.profiles.size, 0); +}); + +test("OpenShell main-era cleanup resumes after provider deletion without losing another source", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_interrupted-delete"); + const other = f.context("cs_delete-survivor"); + await f.driver.registerSource(context, f.input()); + await f.driver.registerSource(other, f.input("other-key")); + const [provider, survivor] = f.providers.values(); + const profile = f.profiles.get(provider.name); + const unmarked = { + ...profile, + annotations: { + "openclaw.dev/profile-digest": profile.annotations["openclaw.dev/profile-digest"], + }, + }; + f.profiles.set(provider.name, unmarked); + const survivorProfile = structuredClone(f.profiles.get(survivor.name)); + const deleteProfile = f.client.deleteProviderProfile; + const crash = new Error("controller stopped before deleting profile"); + f.client.deleteProviderProfile = async () => { + throw crash; + }; + // Provider deletion has committed when the process loses the profile deletion attempt. + await assert.rejects(f.driver.removeSource(context), (error) => error === crash); + assert.equal(f.providers.has(provider.name), false); + assert.deepEqual(f.profiles.get(provider.name), unmarked); + f.client.deleteProviderProfile = deleteProfile; + await f.driverWith().removeSource(context); + assert.equal(f.profiles.has(provider.name), false); + assert.deepEqual(f.providers.get(survivor.name), survivor); + assert.deepEqual(f.profiles.get(survivor.name), survivorProfile); +}); + +test("OpenShell concurrent registrations preserve the first provider's credentials", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_concurrent-registration"); + const read = f.client.getProviderProfile; + // Both registrations observe profile absence. Import's create-only contract refuses + // the stale writer; readback recognizes the already stored configuration. + f.client.getProviderProfile = async (...args) => { + const stale = await read(...args); + f.client.getProviderProfile = read; + await f.driverWith().registerSource(context, f.input("winner-key")); + return stale; + }; + assert.deepEqual(await f.driver.registerSource(context, f.input("retry-key")), { + state: "ready", + }); + assert.equal(f.providers.size, 1); + assert.equal(f.profiles.size, 1); + assert.equal([...f.providers.values()][0].credentials.OPENAI_API_KEY, "winner-key"); +}); + +test("OpenShell rejects a foreign provider appearing after preflight without erasing the profile", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_provider-race"); + const create = f.client.createProvider; + let foreign; + f.client.createProvider = async (provider) => { + // Separate RPCs leave a window after profile import. The final duplicate check is required. + foreign = { + ...provider, + labels: { ...provider.labels, "openclaw.dev/credential-source-id": "cs_other" }, + }; + f.providers.set(provider.name, foreign); + return create(provider); + }; + await assert.rejects(f.driver.registerSource(context, f.input()), ScopeViolationError); + await assert.rejects(f.driver.removeSource(context), ScopeViolationError); + assert.deepEqual([...f.providers.values()], [foreign]); + assert.equal(f.profiles.size, 1, "a separate profile import cannot be rolled back atomically"); + assert.deepEqual(f.deletedProfiles, []); +}); + +test("OpenShell registration does not adopt an unknown provider create outcome", async () => { + const f = credentialGatewayOverProviders(); + const context = f.context("cs_unknown-create"); + const create = f.client.createProvider; + const failure = new Error("transport lost after provider create"); + f.client.createProvider = async (provider) => { + await create(provider); + throw failure; + }; + // Even a persisted owned provider cannot turn an unknown create outcome into success. + await assert.rejects(f.driver.registerSource(context, f.input()), (error) => error === failure); + assert.equal(f.providers.size, 1); + assert.equal(f.calls.length, 1); +}); + test("the OpenShell Credential Gateway never attaches a source of another Namespace", async () => { const { context, driver, input, revisionContext, source } = credentialGatewayOverProviders(); const owner = "cs_00000000-0000-4000-8000-0000000000c1";