diff --git a/apps/controller/src/drivers/compute/kubernetes/index.ts b/apps/controller/src/drivers/compute/kubernetes/index.ts index ec4c9888c9..2ac39132ee 100644 --- a/apps/controller/src/drivers/compute/kubernetes/index.ts +++ b/apps/controller/src/drivers/compute/kubernetes/index.ts @@ -2646,14 +2646,16 @@ function referenceSetting(value: unknown, setting: string): string | undefined { // The startup model probe gets only the selected provider's settings, the primary model's entry // and the binding's credential, so a reference there (other than the binding's apiKey) cannot // resolve. Another Compute may resolve it, so Configuration save accepts it; Kubernetes admission -// names the setting, never its value (finding 2828). On Dedicated OpenClaw the native worker -// gets only the selected catalog entries, so the probe gets only the primary model's entry: no -// other entry can supply its endpoint (findings 2882, 2883). +// names the setting, never its value (finding 2828). On Dedicated OpenClaw (`nativeAuthMethod` +// set) the probe gets the primary model's entry as nativeRuntimeConfiguration renders it for the +// native worker: no other entry can supply its endpoint (findings 2882, 2883), and the probe +// resolves the same model ID and checked endpoint as the worker (finding 2903). The checked +// endpoint is the provider default, not a row setting (finding 2904). function harnessProbeConfiguration( configuration: OpenClawConfigurationDocument, embedded = false, settingError: (setting: string, requirement: string) => Error = preparationSettingError, - native = false, + nativeAuthMethod?: HarnessAuthSnapshot["method"], ): object { const model = harnessPrimaryModel(configuration); const { providerId, environmentName } = harnessModelAuthentication(configuration, embedded); @@ -2678,12 +2680,22 @@ function harnessProbeConfiguration( if (fragment !== undefined && environmentName === CUSTOM_MODEL_API_KEY) { fragment.apiKey = { source: "env", provider: "default", id: CUSTOM_MODEL_API_KEY }; } - if (native && fragment !== undefined) { - const matches = nativeOpenAiModelSelections(configuration)?.find( - (selection) => selection.reference === model, - )?.matches; - // nativeRuntimeConfiguration refuses any other count; the probe never gets another entry. - fragment.models = matches?.length === 1 ? [matches[0]!.entry] : []; + if (nativeAuthMethod !== undefined) { + // An authored row may carry the provider prefix (openai/); OpenClaw's probe then cannot + // route a known model whose row endpoint differs from the provider's, while the worker gets + // the split ID. The rendered row also makes its checked endpoint the provider default, so an + // unchecked provider baseUrl never reaches the probe. + const rendered = nativeRuntimeConfiguration(configuration, settingError, nativeAuthMethod); + const { id } = splitModelRef(model); + // The rendering holds one row for each selected model, the primary included. + const primary = rendered.models.providers.openai.models.find((row) => row.id === id)!; + if (fragment !== undefined) { + // The row inherits its endpoint from the provider, so the runtime's upfront credential + // check, which skips rows with their own baseUrl, still applies (finding 2904). + const { baseUrl, ...row } = primary; + fragment.baseUrl = baseUrl; + fragment.models = [row]; + } } return { agents: { @@ -3049,7 +3061,7 @@ function nativeRuntimeConfiguration( configuration: OpenClawConfigurationDocument, createError: (setting: string, requirement: string) => Error, authMethod: HarnessAuthSnapshot["method"], -): object { +) { const defaults = asRecord(asRecord(configuration.agents)?.defaults); if (asRecord(defaults?.embeddedAgent)?.runtimeServer !== undefined) { throw createError( @@ -4694,11 +4706,14 @@ export class KubernetesComputeDriver implements ComputeDriver { "Harness authentication requires a compatible model provider.", ); } + // On Dedicated OpenClaw this renders the native catalog too (nativeRuntimeConfiguration). if (openclaw) { - harnessProbeConfiguration(configuration, embedded, callerSettingError, native); - } - if (native) { - nativeRuntimeConfiguration(configuration, callerSettingError, auth.method); + harnessProbeConfiguration( + configuration, + embedded, + callerSettingError, + native ? auth.method : undefined, + ); } if (codex) { requireCodexGatewayConfigurationShape(configuration); @@ -15162,7 +15177,7 @@ chmodSync(${JSON.stringify(nodeStatePath)}, 0o700);`; revision.configuration, embedded, preparationSettingError, - revision.harness.mode === "dedicated", + revision.harness.mode === "dedicated" ? revision.harnessAuth.method : undefined, ), ), }, diff --git a/docs/flows/harness-execution-topology.md b/docs/flows/harness-execution-topology.md index 1b2fc27af1..ab422636e5 100644 --- a/docs/flows/harness-execution-topology.md +++ b/docs/flows/harness-execution-topology.md @@ -154,7 +154,8 @@ checks each selected model (`apps/controller/src/drivers/native-openai-catalog.t the selection Compute renders) against the immutable source endpoint before attachment, and a refusal names the `baseUrl` setting and both hosts. Catalog ids match after trimming, as OpenClaw merges such entries, so a second match -fails. The startup model probe gets only the primary model's entry. +fails. The startup model probe gets only the primary model's entry as rendered +for the worker, with its endpoint moved to the provider default. `apps/controller/src/drivers/compute/kubernetes/runtime-entrypoints.ts:NATIVE_WORKER_ENTRYPOINT` writes capacity/isolation settings and Driver-provided `OPENCLAW_WORKSPACE_DIR`; Compute and Sandbox supply private temporary directories. diff --git a/tests/conformance/kubernetes-compute.test.mjs b/tests/conformance/kubernetes-compute.test.mjs index a16abf63bc..d081658460 100644 --- a/tests/conformance/kubernetes-compute.test.mjs +++ b/tests/conformance/kubernetes-compute.test.mjs @@ -1704,6 +1704,9 @@ test("native OpenClaw preparation and activation use only outbound Sandbox enrol "https://models.example.test:443/inference/v1/"; nativeConfiguration.models.providers.openai.api = "openai-completions"; const [selectedEntry] = nativeConfiguration.models.providers.openai.models; + // OpenClaw also names a catalog entry by the full reference; the worker and the startup probe + // get the split model ID (finding 2903). + selectedEntry.id = "openai/team/model"; // The worker gets every selected model; the startup probe checks only the primary. nativeConfiguration.agents.defaults.model = { primary: "openai/team/model", @@ -1847,14 +1850,22 @@ test("native OpenClaw preparation and activation use only outbound Sandbox enrol nativeConfig.models.providers.openai.models.map(({ id }) => id), ["team/model", "team/fallback"], ); - // OpenClaw merges catalog entries whose ids match after trimming, so the startup probe gets - // only the primary model's entry, as written (findings 2882, 2883). + // The startup probe gets only the primary model's entry (OpenClaw merges entries whose ids + // match after trimming; findings 2882, 2883), as the worker gets it: the split model ID, and + // the checked endpoint as the provider default. OpenClaw refuses to probe a known model under + // the authored prefix when its endpoint differs from the provider's (finding 2903), and the + // unchecked provider baseUrl never reaches the probe. The row carries no baseUrl of its own: + // the runtime's upfront credential check skips rows that do (finding 2904). const probeConfig = JSON.parse( provisions[0].requirements.environment.find( ({ name }) => name === "OPENCLAW_HARNESS_PROBE_CONFIG", ).value, ); - assert.deepEqual(probeConfig.models.providers.openai.models, [selectedEntry]); + const { baseUrl: workerEndpoint, ...workerPrimary } = + nativeConfig.models.providers.openai.models[0]; + assert.equal(workerEndpoint, "https://models.example.test/inference/v1"); + assert.deepEqual(probeConfig.models.providers.openai.models, [workerPrimary]); + assert.equal(probeConfig.models.providers.openai.baseUrl, workerEndpoint); // The provider's observed Pod readiness and enrolled node are independent of provisioning. // Until the provider's Pod is Ready, the wait has no named reason. const podPending = await prepare();