mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(codex): support app-server secret refs (#94324)
Co-authored-by: kevinlin-openai <kevin@dendron.so>
This commit is contained in:
@@ -91,8 +91,8 @@ Supported `appServer` fields:
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
|
||||
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
@@ -149,11 +149,15 @@ must report stable version `0.125.0` or newer.
|
||||
|
||||
OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires
|
||||
identity-bearing WebSocket auth through `appServer.authToken` or an
|
||||
`Authorization` header. When native Codex plugins are configured, OpenClaw uses
|
||||
the connected app-server's plugin control plane to install or refresh those
|
||||
plugins and then refreshes app inventory so plugin-owned apps are visible to the
|
||||
Codex thread. Only connect OpenClaw to remote app-servers that are trusted to
|
||||
accept OpenClaw-managed plugin installs and app inventory refreshes.
|
||||
`Authorization` header. `appServer.authToken` and each `appServer.headers.*`
|
||||
value can be a SecretInput; the secrets runtime resolves SecretRefs and env
|
||||
shorthand before OpenClaw builds app-server start options, and unresolved
|
||||
structured SecretRefs fail before any token or header is sent. When native Codex
|
||||
plugins are configured, OpenClaw uses the connected app-server's plugin control
|
||||
plane to install or refresh those plugins and then refreshes app inventory so
|
||||
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
|
||||
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
|
||||
and app inventory refreshes.
|
||||
|
||||
## Approval and sandbox modes
|
||||
|
||||
|
||||
@@ -552,8 +552,8 @@ Supported `appServer` fields:
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. Accepts a literal string or SecretInput such as `${CODEX_APP_SERVER_TOKEN}`. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. Header values accept literal strings or SecretInput values, for example `x-codex-client-session-token: "${CODEX_CLIENT_SESSION_TOKEN}"`. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
|
||||
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
|
||||
| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. |
|
||||
|
||||
@@ -43,6 +43,8 @@ Scope intent:
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `plugins.entries.acpx.config.mcpServers.*.env.*`
|
||||
- `plugins.entries.brave.config.webSearch.apiKey`
|
||||
- `plugins.entries.codex.config.appServer.authToken`
|
||||
- `plugins.entries.codex.config.appServer.headers.*`
|
||||
- `plugins.entries.exa.config.webSearch.apiKey`
|
||||
- `plugins.entries.google-meet.config.realtime.providers.*.apiKey`
|
||||
- `plugins.entries.google.config.webSearch.apiKey`
|
||||
|
||||
@@ -554,6 +554,20 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.codex.config.appServer.authToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.codex.config.appServer.authToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.codex.config.appServer.headers.*",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.codex.config.appServer.headers.*",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.exa.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -152,10 +152,10 @@
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"authToken": { "type": "string" },
|
||||
"authToken": { "type": ["string", "object"] },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
"additionalProperties": { "type": ["string", "object"] }
|
||||
},
|
||||
"clearEnv": {
|
||||
"type": "array",
|
||||
@@ -254,6 +254,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"secretInputs": {
|
||||
"paths": [
|
||||
{ "path": "appServer.authToken", "expected": "string" },
|
||||
{ "path": "appServer.headers.*", "expected": "string" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"codexDynamicToolsLoading": {
|
||||
"label": "Dynamic Tools Loading",
|
||||
@@ -382,6 +390,7 @@
|
||||
"appServer.headers": {
|
||||
"label": "Headers",
|
||||
"help": "Additional headers sent to the WebSocket app-server.",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.clearEnv": {
|
||||
|
||||
@@ -28,6 +28,10 @@ function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) {
|
||||
return resolveCodexAppServerRuntimeOptions({ env: {}, requirementsToml: null, ...params });
|
||||
}
|
||||
|
||||
function envRef(id: string) {
|
||||
return { source: "env" as const, provider: "default", id };
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -413,6 +417,65 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved app-server SecretInput strings through to auth token and headers", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: " resolved-capability-token ",
|
||||
headers: {
|
||||
" x-codex-client-session-token ": " resolved-session-token ",
|
||||
Authorization: " Bearer explicit-token ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectFields(runtime.start, "runtime start", {
|
||||
authToken: "resolved-capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": "resolved-session-token",
|
||||
Authorization: "Bearer explicit-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server auth token SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: envRef("CODEX_APP_SERVER_TOKEN"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.authToken: unresolved SecretRef "env:default:CODEX_APP_SERVER_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unresolved app-server header SecretRefs at runtime option resolution", () => {
|
||||
expect(() =>
|
||||
resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: "capability-token",
|
||||
headers: {
|
||||
"x-codex-client-session-token": envRef("CODEX_CLIENT_SESSION_TOKEN"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(
|
||||
'plugins.entries.codex.config.appServer.headers.x-codex-client-session-token: unresolved SecretRef "env:default:CODEX_CLIENT_SESSION_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats IPv6 loopback websocket app-servers as local loopback", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
@@ -2314,6 +2377,47 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(second).not.toContain("sk-second");
|
||||
});
|
||||
|
||||
it("derives distinct shared-client keys for distinct headers without exposing them", () => {
|
||||
const first = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
});
|
||||
const second = codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer second",
|
||||
"x-codex-client-session-token": "session-second",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(
|
||||
codexAppServerStartOptionsKey({
|
||||
transport: "websocket",
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
headers: {
|
||||
Authorization: "Bearer first",
|
||||
"x-codex-client-session-token": "session-first",
|
||||
},
|
||||
}),
|
||||
).toEqual(first);
|
||||
expect(first).not.toContain("Bearer first");
|
||||
expect(first).not.toContain("session-first");
|
||||
expect(second).not.toContain("Bearer second");
|
||||
expect(second).not.toContain("session-second");
|
||||
});
|
||||
|
||||
it("keeps secret-derived shared-client keys stable across module reloads", async () => {
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
normalizeResolvedSecretInputString,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
@@ -211,8 +216,8 @@ export type CodexPluginConfig = {
|
||||
command?: string;
|
||||
args?: string[] | string;
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
headers?: Record<string, string>;
|
||||
authToken?: SecretInput;
|
||||
headers?: Record<string, SecretInput>;
|
||||
clearEnv?: string[];
|
||||
remoteWorkspaceRoot?: string;
|
||||
codeModeOnly?: boolean;
|
||||
@@ -294,6 +299,7 @@ const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE_PREFIX = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const SecretInputSchema = buildSecretInputSchema();
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
const codexAppServerApprovalPolicySchema = z.enum([
|
||||
"never",
|
||||
@@ -387,8 +393,8 @@ const codexPluginConfigSchema = z
|
||||
command: z.string().optional(),
|
||||
args: z.union([z.array(z.string()), z.string()]).optional(),
|
||||
url: z.string().optional(),
|
||||
authToken: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authToken: SecretInputSchema.optional(),
|
||||
headers: z.record(z.string(), SecretInputSchema).optional(),
|
||||
clearEnv: z.array(z.string()).optional(),
|
||||
remoteWorkspaceRoot: codexAppServerRemoteWorkspaceRootSchema.optional(),
|
||||
codeModeOnly: z.boolean().optional(),
|
||||
@@ -531,7 +537,10 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
|
||||
const headers = normalizeHeaders(config.headers);
|
||||
const clearEnv = normalizeStringList(config.clearEnv);
|
||||
const authToken = readNonEmptyString(config.authToken);
|
||||
const authToken = normalizeCodexAppServerSecretInput({
|
||||
value: config.authToken,
|
||||
path: "plugins.entries.codex.config.appServer.authToken",
|
||||
});
|
||||
const url = readNonEmptyString(config.url);
|
||||
const connectionClass = inferCodexAppServerConnectionClass({ transport, url });
|
||||
const remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate = "preconfigured";
|
||||
@@ -868,9 +877,9 @@ export function codexAppServerStartOptionsKey(
|
||||
args: options.args,
|
||||
url: options.url ?? null,
|
||||
authToken: hashSecretForKey(options.authToken, "authToken"),
|
||||
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
headers: Object.entries(options.headers)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `header:${key}`)]),
|
||||
env: Object.entries(options.env ?? {})
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
|
||||
@@ -2037,11 +2046,27 @@ function normalizeHeaders(value: unknown): Record<string, string> {
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
|
||||
.map(
|
||||
([key, child]) =>
|
||||
[
|
||||
key.trim(),
|
||||
normalizeCodexAppServerSecretInput({
|
||||
value: child,
|
||||
path: `plugins.entries.codex.config.appServer.headers.${key}`,
|
||||
}),
|
||||
] as const,
|
||||
)
|
||||
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCodexAppServerSecretInput(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
return normalizeResolvedSecretInputString(params);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
return normalizeTrimmedStringList(value);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Resolves plugin config contract metadata for scanners and secret/config policy checks. */
|
||||
import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { findBundledPluginMetadataById } from "./bundled-plugin-metadata.js";
|
||||
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginManifestConfigContracts } from "./manifest.js";
|
||||
@@ -60,6 +61,15 @@ export function resolvePluginConfigContractsById(params: {
|
||||
for (const plugin of registry.plugins) {
|
||||
bundledContractFallbacks.set(plugin.id, plugin.configContracts);
|
||||
}
|
||||
if (bundledContractFallbacks.get(pluginId) === undefined) {
|
||||
const bundledMetadata = findBundledPluginMetadataById(pluginId, {
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
});
|
||||
if (bundledMetadata?.manifest.configContracts) {
|
||||
bundledContractFallbacks.set(pluginId, bundledMetadata.manifest.configContracts);
|
||||
}
|
||||
}
|
||||
if (!bundledContractFallbacks.has(pluginId)) {
|
||||
bundledContractFallbacks.set(pluginId, undefined);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,86 @@ function envRef(id: string) {
|
||||
}
|
||||
|
||||
describe("collectPluginConfigAssignments bundled plugin manifests", () => {
|
||||
it("collects Codex app-server SecretRefs from bundled manifest contracts", () => {
|
||||
expect(
|
||||
findBundledPluginMetadataById("codex", {
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
})?.manifest.configContracts?.secretInputs?.paths,
|
||||
).toEqual([
|
||||
{ path: "appServer.authToken", expected: "string" },
|
||||
{ path: "appServer.headers.*", expected: "string" },
|
||||
]);
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: "$CODEX_APP_SERVER_TOKEN",
|
||||
headers: {
|
||||
Authorization: "Bearer literal-token",
|
||||
"x-codex-client-session-token": envRef("CODEX_CLIENT_SESSION_TOKEN"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(
|
||||
resolvePluginConfigContractsById({
|
||||
config,
|
||||
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
|
||||
env: {},
|
||||
fallbackToBundledMetadata: true,
|
||||
fallbackToBundledMetadataForResolvedBundled: true,
|
||||
pluginIds: ["codex"],
|
||||
fallbackBundledPluginIds: ["codex"],
|
||||
}).get("codex")?.configContracts.secretInputs?.paths,
|
||||
).toEqual([
|
||||
{ path: "appServer.authToken", expected: "string" },
|
||||
{ path: "appServer.headers.*", expected: "string" },
|
||||
]);
|
||||
const context = createResolverContext({
|
||||
sourceConfig: config,
|
||||
env: {},
|
||||
});
|
||||
|
||||
collectPluginConfigAssignments({
|
||||
config,
|
||||
defaults: undefined,
|
||||
context,
|
||||
loadablePluginOrigins: new Map([["codex", "bundled"]]),
|
||||
});
|
||||
|
||||
expect({
|
||||
assignments: context.assignments.map((assignment) => assignment.path).toSorted(),
|
||||
warnings: context.warnings,
|
||||
}).toEqual({
|
||||
assignments: [
|
||||
"plugins.entries.codex.config.appServer.authToken",
|
||||
"plugins.entries.codex.config.appServer.headers.x-codex-client-session-token",
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
context.assignments[0]?.apply("resolved-app-server-token");
|
||||
context.assignments[1]?.apply("resolved-session-token");
|
||||
expect(config.plugins?.entries?.codex?.config).toMatchObject({
|
||||
appServer: {
|
||||
authToken: "resolved-app-server-token",
|
||||
headers: {
|
||||
Authorization: "Bearer literal-token",
|
||||
"x-codex-client-session-token": "resolved-session-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("collects voice-call SecretRef assignments from bundled manifest contracts", () => {
|
||||
expect(
|
||||
findBundledPluginMetadataById("voice-call", {
|
||||
|
||||
@@ -3,8 +3,15 @@ import { describe, expect, it } from "vitest";
|
||||
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
|
||||
|
||||
const EMPTY_LOADABLE_PLUGIN_ORIGINS = new Map();
|
||||
const BUNDLED_CODEX_PLUGIN_ORIGINS = new Map([["codex", "bundled" as const]]);
|
||||
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
|
||||
|
||||
const CODEX_APP_SERVER_TOKEN_REF = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "CODEX_APP_SERVER_TOKEN",
|
||||
} as const;
|
||||
|
||||
function expectWarning(
|
||||
snapshot: Awaited<ReturnType<typeof prepareSecretsRuntimeSnapshot>>,
|
||||
expected: { code: string; path: string },
|
||||
@@ -90,6 +97,78 @@ describe("secrets runtime snapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves active bundled Codex app-server plugin SecretRefs", async () => {
|
||||
const snapshot = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: CODEX_APP_SERVER_TOKEN_REF,
|
||||
headers: {
|
||||
Authorization: "Bearer literal-token",
|
||||
"x-codex-client-session-token": "${CODEX_CLIENT_SESSION_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
CODEX_APP_SERVER_TOKEN: "resolved-app-server-token",
|
||||
CODEX_CLIENT_SESSION_TOKEN: "resolved-session-token",
|
||||
},
|
||||
includeAuthStoreRefs: false,
|
||||
loadablePluginOrigins: BUNDLED_CODEX_PLUGIN_ORIGINS,
|
||||
});
|
||||
|
||||
expect(snapshot.config.plugins?.entries?.codex?.config).toMatchObject({
|
||||
appServer: {
|
||||
authToken: "resolved-app-server-token",
|
||||
headers: {
|
||||
Authorization: "Bearer literal-token",
|
||||
"x-codex-client-session-token": "resolved-session-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails active bundled Codex app-server plugin SecretRefs when env is missing", async () => {
|
||||
await expect(
|
||||
prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
appServer: {
|
||||
transport: "websocket",
|
||||
url: "wss://codex-app-server.example.internal/ws",
|
||||
authToken: CODEX_APP_SERVER_TOKEN_REF,
|
||||
headers: {
|
||||
"x-codex-client-session-token": "${CODEX_CLIENT_SESSION_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
CODEX_CLIENT_SESSION_TOKEN: "resolved-session-token",
|
||||
},
|
||||
includeAuthStoreRefs: false,
|
||||
loadablePluginOrigins: BUNDLED_CODEX_PLUGIN_ORIGINS,
|
||||
}),
|
||||
).rejects.toThrow('Environment variable "CODEX_APP_SERVER_TOKEN" is missing or empty.');
|
||||
});
|
||||
|
||||
it("fails when an active exec ref id contains traversal segments", async () => {
|
||||
await expect(
|
||||
prepareSecretsRuntimeSnapshot({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Builds the static and plugin-derived registry of secret migration targets. */
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
|
||||
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
|
||||
@@ -66,7 +67,7 @@ function listBundledWebProviderSecretTargetRegistryEntries(
|
||||
}
|
||||
|
||||
function listBundledPluginConfigSecretTargetRegistryEntries(
|
||||
bundledPlugins: readonly PluginManifestRecord[],
|
||||
bundledPlugins: readonly Pick<PluginManifestRecord, "id" | "configContracts">[],
|
||||
): SecretTargetRegistryEntry[] {
|
||||
const entries: SecretTargetRegistryEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -85,6 +86,24 @@ function listBundledPluginConfigSecretTargetRegistryEntries(
|
||||
return entries.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function listSourceBundledPluginConfigContractRecords(): Array<
|
||||
Pick<PluginManifestRecord, "id" | "configContracts">
|
||||
> {
|
||||
return listBundledPluginMetadata({
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
}).flatMap((metadata) =>
|
||||
metadata.manifest.configContracts
|
||||
? [
|
||||
{
|
||||
id: metadata.manifest.id,
|
||||
configContracts: metadata.manifest.configContracts,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function listChannelSecretTargetRegistryEntries(
|
||||
channelPlugins: readonly PluginManifestRecord[],
|
||||
): SecretTargetRegistryEntry[] {
|
||||
@@ -491,7 +510,10 @@ function loadSecretTargetRegistryFromPluginMetadata(params: {
|
||||
return [
|
||||
...CORE_SECRET_TARGET_REGISTRY,
|
||||
...listBundledWebProviderSecretTargetRegistryEntries(bundledPlugins),
|
||||
...listBundledPluginConfigSecretTargetRegistryEntries(bundledPlugins),
|
||||
...listBundledPluginConfigSecretTargetRegistryEntries([
|
||||
...bundledPlugins,
|
||||
...listSourceBundledPluginConfigContractRecords(),
|
||||
]),
|
||||
...listChannelSecretTargetRegistryEntries(channelPlugins),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("secret target registry", () => {
|
||||
it("derives bundled plugin SecretInput contract target paths from plugin manifests", () => {
|
||||
const coreTargetIds = new Set(getCoreSecretTargetRegistry().map((entry) => entry.id));
|
||||
expect(coreTargetIds.has("plugins.entries.voice-call.config.twilio.authToken")).toBe(false);
|
||||
expect(coreTargetIds.has("plugins.entries.codex.config.appServer.authToken")).toBe(false);
|
||||
|
||||
const target = resolveConfigSecretTargetByPath([
|
||||
"plugins",
|
||||
@@ -100,5 +101,30 @@ describe("secret target registry", () => {
|
||||
]);
|
||||
|
||||
expect(target?.entry?.id).toBe("plugins.entries.voice-call.config.tts.providers.*.apiKey");
|
||||
|
||||
const codexAuthTarget = resolveConfigSecretTargetByPath([
|
||||
"plugins",
|
||||
"entries",
|
||||
"codex",
|
||||
"config",
|
||||
"appServer",
|
||||
"authToken",
|
||||
]);
|
||||
expect(codexAuthTarget?.entry?.id).toBe(
|
||||
"plugins.entries.codex.config.appServer.authToken",
|
||||
);
|
||||
|
||||
const codexHeaderTarget = resolveConfigSecretTargetByPath([
|
||||
"plugins",
|
||||
"entries",
|
||||
"codex",
|
||||
"config",
|
||||
"appServer",
|
||||
"headers",
|
||||
"x-codex-client-session-token",
|
||||
]);
|
||||
expect(codexHeaderTarget?.entry?.id).toBe(
|
||||
"plugins.entries.codex.config.appServer.headers.*",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user