mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(secrets): authenticated egress substitution proxy with destination binding (#123216)
* feat(secrets): add authenticated egress substitution proxy * feat(secrets): bind egress substitution to hosts * ci(codeql): classify egress proxy bypass tunnel in network boundary query * refactor(proxy-capture): use the canonical IP parser instead of node:net * fix(secrets): compare proxy tokens with a process-keyed MAC
This commit is contained in:
committed by
GitHub
parent
2a7295a131
commit
b9c6789560
@@ -14,6 +14,7 @@ paths:
|
||||
- src/infra/push-apns-http2.ts
|
||||
- src/infra/ssh-tunnel.ts
|
||||
- src/proxy-capture
|
||||
- src/secrets/egress-proxy
|
||||
- extensions/codex/src/app-server/transport-websocket.ts
|
||||
- extensions/irc/src
|
||||
- extensions/qa-lab/src
|
||||
|
||||
@@ -73,6 +73,11 @@ predicate allowedRawSocketClientCall(Expr call) {
|
||||
or
|
||||
allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer")
|
||||
or
|
||||
// Bypass hosts are blind TLS tunnels for pinned clients: the proxy relays bytes it
|
||||
// deliberately cannot read, so no secret substitution happens on this route. Every
|
||||
// substituting route uses the managed https client, not a raw socket.
|
||||
allowedOwnerScope(call, "src/secrets/egress-proxy/proxy-server.ts", "startSecretEgressProxyServer")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/codex/src/app-server/transport-websocket.ts", "connectCodexAppServerUnixSocket")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient")
|
||||
|
||||
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress.
|
||||
- **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts.
|
||||
- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched.
|
||||
- **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees.
|
||||
|
||||
@@ -4745,6 +4745,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable {
|
||||
public let updatedatms: Int
|
||||
public let updatedby: String?
|
||||
public let kind: String
|
||||
public let allowedhosts: [String]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -4753,7 +4754,8 @@ public struct SecretStoreSecretEntry: Codable, Sendable {
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
updatedby: String? = nil,
|
||||
kind: String)
|
||||
kind: String,
|
||||
allowedhosts: [String]? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.scopekind = scopekind
|
||||
@@ -4762,6 +4764,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable {
|
||||
self.updatedatms = updatedatms
|
||||
self.updatedby = updatedby
|
||||
self.kind = kind
|
||||
self.allowedhosts = allowedhosts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -4772,6 +4775,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable {
|
||||
case updatedatms = "updatedAtMs"
|
||||
case updatedby = "updatedBy"
|
||||
case kind
|
||||
case allowedhosts = "allowedHosts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4837,21 +4841,25 @@ public struct SecretsStoreSetParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let value: String
|
||||
public let kind: AnyCodable
|
||||
public let allowedhosts: [String]?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
value: String,
|
||||
kind: AnyCodable)
|
||||
kind: AnyCodable,
|
||||
allowedhosts: [String]? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.kind = kind
|
||||
self.allowedhosts = allowedhosts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case value
|
||||
case kind
|
||||
case allowedhosts = "allowedHosts"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2308,
|
||||
"core": 2312,
|
||||
"channel": 3575,
|
||||
"plugin": 3997
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ff3a4f23b20f40e493f2c5d4da1a8c6fba0206cf763a372e6437a3cf515d1099 config-baseline.json
|
||||
d834d843e6f65a87490964c1e7a089ec8421b546a9274b84c5d2ef35ec14f5a1 config-baseline.core.json
|
||||
617f7a9ff716a0d79aaaf779905c37e4470d647459f13ceb14791d9cbb4df747 config-baseline.json
|
||||
3a5d877e6697388e8b61e5a9e539a1fad7113118453276fe24fb6772abaeae97 config-baseline.core.json
|
||||
1144184911193a239dd0e6415335a2a95771af27c4b1f5974e64851d6b2ed65d config-baseline.channel.json
|
||||
250f573a93619d8a2f554028288af84551e572ef8bfdc820a43024572b55175a config-baseline.plugin.json
|
||||
|
||||
+13
-1
@@ -82,6 +82,18 @@ openclaw secrets store set TLS_PRIVATE_KEY \
|
||||
|
||||
`set` is idempotent and updates an existing name. Add `--dry-run` to validate and preview the operation without writing. A successful write reminds you to run `openclaw secrets reload` before a config-referenced value can take effect.
|
||||
|
||||
Secret egress substitution fails closed until each secret has at least one exact allowed host. Bind or replace hosts with repeatable `--allow-host` flags; this policy-only form does not ask for or replace an existing secret value:
|
||||
|
||||
```bash
|
||||
openclaw secrets store set OPENAI_API_KEY --allow-host api.openai.com
|
||||
openclaw secrets store set SERVICE_TOKEN \
|
||||
--allow-host api.example.com \
|
||||
--allow-host uploads.example.com
|
||||
openclaw secrets store set SERVICE_TOKEN --clear-allowed-hosts
|
||||
```
|
||||
|
||||
Hosts are normalized to lowercase ASCII/punycode. Schemes, paths, ports, and wildcards are rejected. `store list` shows allowed hosts because they are policy metadata, not secret material.
|
||||
|
||||
### Read values
|
||||
|
||||
```bash
|
||||
@@ -92,7 +104,7 @@ openclaw secrets store get LOG_LEVEL
|
||||
|
||||
Secret values never appear in human, `--json`, or `--plain` output. `store get` refuses a `secret` entry as write-only by design and exits `2`; it exits `3` when the name does not exist. Environment-kind values are readable.
|
||||
|
||||
Team-scoped `env` entries also reach commands run by OpenClaw's own exec tool, including Code Mode, sandboxed exec, and `node`-hosted exec. Explicit per-call env wins over store values, and host/sandbox security filters can reject protected or credential-shaped names with a warning. `secret` entries are never exposed as subprocess env; use them through `store` SecretRefs instead.
|
||||
Team-scoped `env` entries also reach commands run by OpenClaw's own exec tool, including Code Mode, sandboxed exec, and `node`-hosted exec. Explicit per-call env wins over store values, and host/sandbox security filters can reject protected or credential-shaped names with a warning. `secret` entries stay out of subprocesses by default. With `secrets.egressProxy.enabled: true`, Gateway-hosted exec receives only authenticated sentinels and the Gateway replaces them at HTTPS egress; see [Secret egress proxy](/gateway/secrets#secret-egress-proxy).
|
||||
|
||||
<Warning>
|
||||
Store entries do not reach commands run inside an external agent harness. The Codex app-server and its sandbox exec-server, and ACP children such as Claude Code, build their own child environment and never pass through OpenClaw's exec preparation. If an agent run is delegated to one of those harnesses, set the variable in that harness's own configuration instead.
|
||||
|
||||
@@ -1260,6 +1260,26 @@ Reference env vars in any config string with `${VAR_NAME}`:
|
||||
|
||||
Secret refs are additive: plaintext values still work.
|
||||
|
||||
### `secrets.egressProxy`
|
||||
|
||||
Default-off Gateway-owned substitution for shared-store `secret` entries used by agent exec subprocesses:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
egressProxy: {
|
||||
enabled: false,
|
||||
bypassHosts: ["pinned-api.example.com"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `enabled`: starts the loopback proxy and ephemeral CA at Gateway startup. Default: `false`. Changing it requires a Gateway restart.
|
||||
- `bypassHosts`: optional exact-hostname list for authenticated blind CONNECT tunnels used by certificate-pinned clients. Sentinels are not substituted on bypassed hosts and fail vendor authentication without exposing plaintext.
|
||||
|
||||
See [Secret egress proxy](/gateway/secrets#secret-egress-proxy) for subprocess environment wiring, authentication, fail-closed behavior, and limitations.
|
||||
|
||||
### `SecretRef`
|
||||
|
||||
Use one object shape:
|
||||
|
||||
@@ -461,6 +461,8 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
|
||||
|
||||
By default, local container sandboxes run with **no network**. Override with `agents.defaults.sandbox.docker.network`.
|
||||
|
||||
The default-off [secret egress proxy](/gateway/secrets#secret-egress-proxy) is Gateway-loopback only. Sandbox exec receives the proxy and CA environment variables when the feature is enabled, but container loopback does not reach the Gateway host, and the default `network: "none"` blocks egress entirely. Sandbox/container proxy reachability is not implemented; do not enable sandbox networking expecting secret substitution to work in this release.
|
||||
|
||||
<Note>
|
||||
Package installation and certificate-store changes are image provisioning, not
|
||||
normal sandbox-turn behavior. The defaults deliberately combine no network,
|
||||
|
||||
+64
-2
@@ -34,7 +34,7 @@ Gateway ingress protection, structurally invalid config or resolved values, poli
|
||||
|
||||
## Egress-time injection (sentinels)
|
||||
|
||||
For model-provider credentials backed by SecretRefs, OpenClaw mints an opaque, process-local sentinel during model-auth resolution. Auth storage, stream options, SDK configuration, logs, error objects, and most runtime introspection therefore see a value such as `oc-sent-v1-...`, not the provider credential. The guarded model fetch and managed local-provider health probes replace known sentinels in URL and header values immediately before each request leaves the process.
|
||||
For model-provider credentials backed by SecretRefs, OpenClaw mints an opaque, process-local sentinel during model-auth resolution. Auth storage, stream options, SDK configuration, logs, error objects, and most runtime introspection therefore see a value such as `oc-sent-v2.<authenticated-ciphertext>.end`, not the provider credential. The guarded model fetch and managed local-provider health probes replace known sentinels in URL and header values immediately before each request leaves the process.
|
||||
|
||||
Unknown sentinel-shaped values fail closed before network activity. OpenClaw refuses to send the request rather than forwarding an unresolved sentinel to a provider. Resolved secret values are also registered for exact-value log redaction as a defense in depth measure.
|
||||
|
||||
@@ -282,7 +282,7 @@ Entries have a `secret` or `env` kind. The kind controls CLI disclosure, not Sec
|
||||
|
||||
It does not cover commands executed inside a provider-native harness — the Codex app-server and its sandbox exec-server, or ACP children such as Claude Code. Those harnesses assemble their own child environment and never pass through OpenClaw's exec preparation, so store entries are absent there. The store snapshot is also read once per agent run, so entries added mid-run apply from the next run onward.
|
||||
|
||||
`secret` entries are never injected into subprocess environments. They remain available only through `store` SecretRefs because plaintext env injection would bypass the store disclosure boundary; safe secret injection requires a future egress-substitution mechanism.
|
||||
By default, `secret` entries are never injected into subprocess environments. When the default-off [secret egress proxy](#secret-egress-proxy) is enabled, Gateway-hosted exec commands receive process-local sentinels instead of plaintext values.
|
||||
|
||||
Names use the same uppercase grammar as env SecretRefs, and each UTF-8 value is limited to 64 KiB (65,536 bytes). A `secret` entry must carry a value; empty secrets are rejected because they would surface only as a confusing downstream auth failure. `env` entries may be empty. This supports PEM keys and service-account JSON without inheriting the smaller limits of ordinary environment variables.
|
||||
|
||||
@@ -306,6 +306,68 @@ Control UI set/delete operations automatically refresh the active secrets runtim
|
||||
Store values are not encrypted at rest. They are stored unencrypted in the shared state SQLite database (`state/openclaw.sqlite`), protected by the same `0600` file and `0700` directory permissions as other credentials in that database. Operators who need stronger storage isolation should use an external exec provider such as the [1Password plugin](/plugins/onepassword) or [Vault SecretRefs](/plugins/vault).
|
||||
</Warning>
|
||||
|
||||
## Secret egress proxy
|
||||
|
||||
The secret egress proxy lets Gateway-hosted agent subprocesses use shared-store `secret` entries without receiving their plaintext. OpenClaw puts the existing authenticated sentinel in the subprocess environment, then a Gateway-owned loopback proxy replaces it in request URLs, headers, and streamed bodies immediately before egress.
|
||||
|
||||
Each secret must also name the exact HTTPS hosts where substitution is allowed. Hostnames are stored lowercase in ASCII/punycode form and matched exactly; wildcards, suffix matching, and ports are not supported. A secret with no allowed hosts is never substituted. Bind a host without replacing the stored value:
|
||||
|
||||
```bash
|
||||
openclaw secrets store set OPENAI_API_KEY --allow-host api.openai.com
|
||||
```
|
||||
|
||||
Repeat `--allow-host` to replace the binding with multiple hosts, or use `--clear-allowed-hosts` to remove every binding. A refused request names the secret and prints the exact `store set ... --allow-host ...` command needed for that destination.
|
||||
|
||||
Enable it explicitly, then restart the Gateway:
|
||||
|
||||
```bash
|
||||
openclaw config set secrets.egressProxy.enabled true --strict-json
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Equivalent config:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
egressProxy: {
|
||||
enabled: true,
|
||||
bypassHosts: ["pinned-api.example.com"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, OpenClaw adds these values to Gateway and sandbox exec environments:
|
||||
|
||||
- `HTTPS_PROXY` and `HTTP_PROXY`, with per-run credentials embedded in the loopback proxy URL
|
||||
- `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, and `REQUESTS_CA_BUNDLE`, pointing at the ephemeral CA certificate
|
||||
- each team-store `secret` entry as an `oc-sent-v2...end` sentinel; `env` entries keep their existing behavior and precedence
|
||||
|
||||
Proxy authentication uses standard Basic proxy auth with username `openclaw` and a random per-run password. The token expires when the exact agent run closes, including cancellation and replacement. Base64 is not treated as encryption: the listener binds only to loopback, and a process that can read the proxy token from the agent environment can already read the sentinels in that environment. Missing, wrong, or expired credentials receive `407 Proxy Authentication Required` and are never forwarded.
|
||||
|
||||
The run snapshot registers each sentinel together with its secret name and allowed hosts. After proxy authentication, the proxy looks up the matched sentinel in that run's registration and authorizes the normalized destination hostname before decrypting the sentinel. A sentinel that is unregistered, unresolved, unbound, or bound to another host is refused before its plaintext is forwarded.
|
||||
|
||||
<Warning>
|
||||
Destination binding does not make an allowed host trustworthy. A bound service that reflects request credentials can still return the plaintext to the agent. DNS-level compromise can redirect a permitted hostname because policy is hostname-based, not an IP pin. Non-HTTPS requests are refused rather than protected, and HTTPS interception still has the protocol limits below. Use external network policy or process isolation when those threats are in scope.
|
||||
</Warning>
|
||||
|
||||
The CA is generated once per Gateway start under the state directory. Its directory is mode `0700`, its private keys are mode `0600`, it is removed during Gateway shutdown, and OpenClaw never installs it in a system trust store. Requests fail closed when a sentinel cannot be authenticated or resolved; the proxy never forwards or silently strips an unresolved sentinel. Request bodies are scanned as a stream with a bounded carry window, so substitution also works when a sentinel crosses chunk boundaries or appears in a large upload.
|
||||
|
||||
`bypassHosts` contains exact hostnames that must remain end-to-end TLS for certificate-pinned clients. Those hosts use an authenticated blind CONNECT tunnel. No substitution is possible inside the tunnel; a sentinel sent there is safe by construction because it is authenticated ciphertext rather than a credential, so the vendor sees an invalid credential and rejects it.
|
||||
|
||||
Current limits:
|
||||
|
||||
- HTTP/2 upstream connections are not supported; the proxy uses HTTP/1.1 upstream.
|
||||
- WebSocket rewriting is not supported.
|
||||
- Non-443 HTTPS substitution is not a supported compatibility target.
|
||||
- Identity-scoped secrets are not supported; only the team store participates.
|
||||
- Allowed-host policy is exact-hostname authorization only. It does not validate the resolved IP or prevent an allowed origin from reflecting credentials.
|
||||
- Plain HTTP is refused; it is not upgraded or substituted.
|
||||
- Sandbox/container reachability is not implemented. Local container sandboxes default to `network: "none"`, and their loopback address is not the Gateway host. The variables are present, but the proxy is normally unreachable.
|
||||
- Remote `node` exec and provider-native harness subprocesses do not use this proxy.
|
||||
- Background subprocesses lose proxy authorization when their owning agent run ends, even if the process itself is still alive.
|
||||
|
||||
## File-backed API keys
|
||||
|
||||
Do not put `file:...` strings in the config `env` block. That block is literal and non-overriding, so `file:...` is never resolved there.
|
||||
|
||||
@@ -76,6 +76,7 @@ Notes:
|
||||
- On non-Windows gateway hosts, bash and zsh exec commands use a startup snapshot. OpenClaw captures sourceable aliases/functions and a small safe environment set from shell startup files into `$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command. Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set `OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
|
||||
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code.
|
||||
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
|
||||
- With the default-off [secret egress proxy](/gateway/secrets#secret-egress-proxy), Gateway-hosted exec receives shared-store `secret` entries only as process-local sentinels. The authenticated loopback proxy substitutes plaintext at outbound HTTPS request time; the exact run token expires when the agent run closes.
|
||||
- For channel-origin runs, OpenClaw also exposes a narrow sender/chat identity JSON payload in `OPENCLAW_CHANNEL_CONTEXT` when the channel provided those ids.
|
||||
- `exec` cannot run `openclaw channels login` or `/approve` shell commands: `openclaw channels login` is an interactive channel-auth flow, and `/approve` needs to go through the approval command handler, not a shell. Run channel login in a terminal on the gateway host, or use a channel-specific login agent tool when one exists (for example `whatsapp_login`).
|
||||
- Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto` resolves to `gateway`. Explicit `host=sandbox` still fails closed instead of silently running on the gateway host. Enable sandboxing or use `host=gateway` with approvals.
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("secret store protocol schemas", () => {
|
||||
expect(
|
||||
Value.Check(SecretsStoreListResultSchema, {
|
||||
entries: [
|
||||
{ ...metadata, kind: "secret" },
|
||||
{ ...metadata, kind: "secret", allowedHosts: ["api.example.com"] },
|
||||
{ ...metadata, name: "SERVICE_URL", kind: "env", value: "https://service.test" },
|
||||
],
|
||||
}),
|
||||
@@ -43,8 +43,17 @@ describe("secret store protocol schemas", () => {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "value",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(SecretsStoreSetParamsSchema, {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "value",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com", "api.example.com"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(SecretsStoreSetParamsSchema, {
|
||||
name: "lowercase",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Type, type Static } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
import { withSince } from "./since.js";
|
||||
|
||||
/**
|
||||
* Secret-provider protocol schemas.
|
||||
@@ -27,10 +28,16 @@ const SecretStoreEntryMetadataProperties = {
|
||||
updatedBy: Type.Optional(Type.String()),
|
||||
} as const;
|
||||
|
||||
const SecretStoreAllowedHostsSchema = Type.Array(Type.String({ minLength: 1, maxLength: 253 }), {
|
||||
maxItems: 128,
|
||||
uniqueItems: true,
|
||||
});
|
||||
|
||||
/** Secret metadata never structurally carries the stored value. */
|
||||
export const SecretStoreSecretEntrySchema = closedObject({
|
||||
...SecretStoreEntryMetadataProperties,
|
||||
kind: Type.Literal("secret"),
|
||||
allowedHosts: Type.Optional(withSince("2026.8", SecretStoreAllowedHostsSchema)),
|
||||
});
|
||||
|
||||
/** Environment entries include their value because they are intentionally visible. */
|
||||
@@ -59,6 +66,7 @@ export const SecretsStoreSetParamsSchema = closedObject({
|
||||
name: SecretStoreNameSchema,
|
||||
value: Type.String({ maxLength: 64 * 1024 }),
|
||||
kind: Type.Union([Type.Literal("secret"), Type.Literal("env")]),
|
||||
allowedHosts: Type.Optional(withSince("2026.8", SecretStoreAllowedHostsSchema)),
|
||||
});
|
||||
|
||||
/** Soft-delete one team secret-store entry. */
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { InputProvenance } from "../sessions/input-provenance.js";
|
||||
import type { SkillSnapshot, SkillUsagePath } from "../skills/types.js";
|
||||
import type { SkillWorkshopRunOptions } from "../skills/workshop/types.js";
|
||||
import { resolveGatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import type { OperationalRunInstanceRef } from "./admitted-run-context.js";
|
||||
import type { ToolOutcomeObserver } from "./agent-tools.before-tool-call.js";
|
||||
import { finalizeAgentTools } from "./agent-tools.finalize.js";
|
||||
import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js";
|
||||
@@ -197,6 +198,8 @@ type OpenClawCodingToolsOptions = {
|
||||
oneShotCliRun?: boolean;
|
||||
/** Stable run identifier for this agent invocation. */
|
||||
runId?: string;
|
||||
/** Exact admitted run instance for lifecycle-bound subprocess capabilities. */
|
||||
operationalRunInstance?: OperationalRunInstanceRef;
|
||||
/** Device-scoped operator session allowed to review approvals initiated by this run. */
|
||||
approvalReviewerDeviceId?: string;
|
||||
/** Diagnostic trace context for hook/log correlation during this run. */
|
||||
@@ -586,6 +589,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
scopeKey,
|
||||
sessionKey: options?.sessionKey,
|
||||
runId: options?.runId,
|
||||
operationalRunInstance: options?.operationalRunInstance,
|
||||
// Detached completions return to the live session, not the sandbox policy scope.
|
||||
notifySessionKey: options?.runSessionKey ?? options?.sessionKey,
|
||||
sessionId: options?.sessionId,
|
||||
|
||||
@@ -356,9 +356,14 @@ export function resolvePreparedExecEnvironment(params: {
|
||||
defaultPathPrepend: string[];
|
||||
pluginEnv?: Record<string, string>;
|
||||
storeEnv?: Record<string, string>;
|
||||
storeSecretEnv?: Record<string, string>;
|
||||
secretEgressEnv?: Record<string, string>;
|
||||
warnings: string[];
|
||||
}): { env: Record<string, string>; requestedEnv?: Record<string, string> } {
|
||||
const inheritedBaseEnv = coerceEnv(process.env);
|
||||
if (params.secretEgressEnv) {
|
||||
Object.assign(inheritedBaseEnv, params.secretEgressEnv);
|
||||
}
|
||||
const channelContextEnv = buildChannelContextEnv(params.channelContext);
|
||||
const explicitEnv: Record<string, string> | undefined =
|
||||
params.execParams.env !== undefined ||
|
||||
@@ -400,20 +405,23 @@ export function resolvePreparedExecEnvironment(params: {
|
||||
);
|
||||
}
|
||||
const hasStoreEnv = storeEnv && Object.keys(storeEnv).length > 0;
|
||||
const requestedEnv: Record<string, string> | undefined = hasStoreEnv
|
||||
const untrustedRequestedEnv: Record<string, string> | undefined = hasStoreEnv
|
||||
? { ...storeEnv, ...explicitEnv }
|
||||
: explicitEnv;
|
||||
const requestedEnv: Record<string, string> | undefined = params.storeSecretEnv
|
||||
? { ...storeEnv, ...params.storeSecretEnv, ...explicitEnv }
|
||||
: untrustedRequestedEnv;
|
||||
const hostEnvResult =
|
||||
params.host === "sandbox"
|
||||
? null
|
||||
: sanitizeHostExecEnvWithDiagnostics({
|
||||
baseEnv: inheritedBaseEnv,
|
||||
overrides: requestedEnv,
|
||||
overrides: untrustedRequestedEnv,
|
||||
blockPathOverrides: true,
|
||||
});
|
||||
if (
|
||||
hostEnvResult &&
|
||||
requestedEnv &&
|
||||
untrustedRequestedEnv &&
|
||||
(hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
|
||||
hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
|
||||
) {
|
||||
@@ -450,7 +458,7 @@ export function resolvePreparedExecEnvironment(params: {
|
||||
params.sandbox && params.host === "sandbox"
|
||||
? buildSandboxEnv({
|
||||
defaultPath: DEFAULT_PATH,
|
||||
paramsEnv: requestedEnv,
|
||||
paramsEnv: untrustedRequestedEnv,
|
||||
sandboxEnv: params.sandbox.env,
|
||||
containerWorkdir: params.containerWorkdir ?? params.sandbox.containerWorkdir,
|
||||
})
|
||||
@@ -474,5 +482,18 @@ export function resolvePreparedExecEnvironment(params: {
|
||||
applyPathPrepend(env, params.defaultPathPrepend);
|
||||
}
|
||||
|
||||
if (params.storeSecretEnv) {
|
||||
// Secret-kind entries are authenticated ciphertext, not active credentials.
|
||||
// Inject them after ordinary env filtering so names such as GH_TOKEN remain usable.
|
||||
for (const [key, value] of Object.entries(params.storeSecretEnv)) {
|
||||
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.secretEgressEnv) {
|
||||
Object.assign(env, params.secretEgressEnv);
|
||||
}
|
||||
|
||||
return { env, requestedEnv };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import { rejectUnsafeExecControlShellCommand } from "../infra/exec-control-comma
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
isSecretEgressProxyActive,
|
||||
registerSecretEgressProxyRun,
|
||||
} from "../secrets/egress-proxy/registry.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
|
||||
import { markBackgrounded } from "./bash-process-registry.js";
|
||||
import { describeExecTool } from "./bash-tools.descriptions.js";
|
||||
@@ -66,18 +70,17 @@ import type { AgentToolWithMeta } from "./tools/common.js";
|
||||
export function createExecTool(
|
||||
defaults?: ExecToolDefaults,
|
||||
): AgentToolWithMeta<typeof execSchema, ExecToolDetails> {
|
||||
const secretEgressEnabled = isSecretEgressProxyActive();
|
||||
// Agent runs own one tool instance, so the store is read on first exec and reused for that run.
|
||||
// A new run constructs a new instance and observes later store mutations.
|
||||
let storeEnvPromise: Promise<Record<string, string> | undefined> | undefined;
|
||||
let storeEnvPromise:
|
||||
| Promise<import("../secrets/store/secret-store.js").SecretStoreExecEnvironment>
|
||||
| undefined;
|
||||
const resolveStoreEnv = () => {
|
||||
storeEnvPromise ??= import("../secrets/store/secret-store.js").then((store) => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const entry of store.listSecretStoreEntries({ scope: { kind: "team" } })) {
|
||||
if (entry.kind === "env" && entry.valuePreview !== undefined) {
|
||||
env[entry.name] = entry.valuePreview;
|
||||
}
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
return store.readSecretStoreExecEnvironment({
|
||||
includeSecretSentinels: secretEgressEnabled,
|
||||
});
|
||||
});
|
||||
return storeEnvPromise;
|
||||
};
|
||||
@@ -401,6 +404,17 @@ export function createExecTool(
|
||||
|
||||
const resolvedExecEnvState = requestPreparation.getResolvedExecEnvPreparedState(params);
|
||||
const storeEnv = await resolveStoreEnv();
|
||||
const canReachGatewayProxy = host === "gateway" || host === "sandbox";
|
||||
let secretEgressEnv: Record<string, string> | undefined;
|
||||
if (secretEgressEnabled && canReachGatewayProxy) {
|
||||
if (!defaults?.operationalRunInstance) {
|
||||
throw new Error("Secret egress proxy requires an admitted agent run instance");
|
||||
}
|
||||
secretEgressEnv = registerSecretEgressProxyRun(
|
||||
defaults.operationalRunInstance,
|
||||
storeEnv.secretEgressBindings ?? [],
|
||||
);
|
||||
}
|
||||
const { env, requestedEnv } = resolvePreparedExecEnvironment({
|
||||
execParams: params,
|
||||
host,
|
||||
@@ -409,7 +423,9 @@ export function createExecTool(
|
||||
channelContext: defaults?.channelContext,
|
||||
defaultPathPrepend,
|
||||
pluginEnv: resolvedExecEnvState?.pluginEnv,
|
||||
storeEnv,
|
||||
storeEnv: storeEnv.env,
|
||||
storeSecretEnv: secretEgressEnv ? storeEnv.secretSentinels : undefined,
|
||||
secretEgressEnv,
|
||||
warnings,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
|
||||
import type { TerminationReason } from "../process/supervisor/types.js";
|
||||
import type { OperationalRunInstanceRef } from "./admitted-run-context.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import type { EmbeddedFullAccessBlockedReason } from "./embedded-agent-runner/types.js";
|
||||
import type { ExecReviewerConfig } from "./exec-auto-reviewer.js";
|
||||
@@ -56,6 +57,8 @@ export type ExecToolDefaults = {
|
||||
sessionKey?: string;
|
||||
/** Stable agent run that owns any approval created by this tool. */
|
||||
runId?: string;
|
||||
/** Exact admitted execution instance that owns secret-egress proxy access. */
|
||||
operationalRunInstance?: OperationalRunInstanceRef;
|
||||
/** Durable session that receives detached exec completion events and approval followups. */
|
||||
notifySessionKey?: string;
|
||||
/** Ephemeral session UUID active when this exec tool was built. Regenerated
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/** Store-backed exec environment tests cover run snapshots, precedence, and security filtering. */
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { looksLikeSecretSentinel, resolveSecretSentinel } from "../secrets/sentinel.js";
|
||||
import { writeSecretStoreEntry } from "../secrets/store/secret-store.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
egressActive: false,
|
||||
proxyUrl: ["http://openclaw:", "fixture-password", "@127.0.0.1:19090"].join(""),
|
||||
gatewayParams: [] as Array<{
|
||||
env: Record<string, string>;
|
||||
requestedEnv?: Record<string, string>;
|
||||
}>,
|
||||
spawnInputs: [] as Array<{ env?: Record<string, string> }>,
|
||||
proxyBindings: [] as Array<unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
@@ -19,6 +23,21 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunnerRegistry: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../secrets/egress-proxy/registry.js", () => ({
|
||||
isSecretEgressProxyActive: () => mocks.egressActive,
|
||||
registerSecretEgressProxyRun: (_run: unknown, bindings: unknown) => {
|
||||
mocks.proxyBindings.push(bindings);
|
||||
return {
|
||||
HTTPS_PROXY: mocks.proxyUrl,
|
||||
HTTP_PROXY: mocks.proxyUrl,
|
||||
NODE_EXTRA_CA_CERTS: "/state/secret-egress/root-ca.pem",
|
||||
SSL_CERT_FILE: "/state/secret-egress/root-ca.pem",
|
||||
CURL_CA_BUNDLE: "/state/secret-egress/root-ca.pem",
|
||||
REQUESTS_CA_BUNDLE: "/state/secret-egress/root-ca.pem",
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../infra/shell-env.js", () => ({
|
||||
getShellEnvAppliedKeys: vi.fn(() => []),
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
@@ -70,7 +89,12 @@ vi.mock("../process/supervisor/index.js", () => ({
|
||||
let createExecTool: typeof import("./bash-tools.exec-run.js").createExecTool;
|
||||
let createLazyExecTool: typeof import("./lazy-exec-tool.js").createLazyExecTool;
|
||||
|
||||
type StoreEntry = { name: string; value: string; kind: "env" | "secret" };
|
||||
type StoreEntry = {
|
||||
name: string;
|
||||
value: string;
|
||||
kind: "env" | "secret";
|
||||
allowedHosts?: string[];
|
||||
};
|
||||
|
||||
async function withTeamStoreEntries(
|
||||
entries: StoreEntry[],
|
||||
@@ -99,8 +123,10 @@ describe("exec store environment", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.egressActive = false;
|
||||
mocks.gatewayParams.length = 0;
|
||||
mocks.spawnInputs.length = 0;
|
||||
mocks.proxyBindings.length = 0;
|
||||
});
|
||||
|
||||
it("adds only team env-kind entries to gateway exec subprocesses", async () => {
|
||||
@@ -268,4 +294,96 @@ describe("exec store environment", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps disabled secret egress byte-identical with a secret-kind store entry", async () => {
|
||||
await withTeamStoreEntries(
|
||||
[
|
||||
{
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "disabled-secret",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com"],
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
const absentConfig = createExecTool({ host: "gateway", security: "full", ask: "off" });
|
||||
await absentConfig.execute("call-egress-absent", {
|
||||
command: "echo ok",
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
const baseline = JSON.stringify({
|
||||
gateway: mocks.gatewayParams[0],
|
||||
spawn: mocks.spawnInputs[0],
|
||||
});
|
||||
mocks.gatewayParams.length = 0;
|
||||
mocks.spawnInputs.length = 0;
|
||||
|
||||
const explicitFalse = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
config: { secrets: { egressProxy: { enabled: false } } },
|
||||
});
|
||||
await explicitFalse.execute("call-egress-disabled", {
|
||||
command: "echo ok",
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.stringify({ gateway: mocks.gatewayParams[0], spawn: mocks.spawnInputs[0] }),
|
||||
).toBe(baseline);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("injects proxy trust and secret-kind sentinels without changing env-kind entries", async () => {
|
||||
await withTeamStoreEntries(
|
||||
[
|
||||
{ name: "AWS_REGION", value: "us-west-2", kind: "env" },
|
||||
{
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "enabled-secret",
|
||||
kind: "secret",
|
||||
allowedHosts: ["API.EXAMPLE.COM"],
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
mocks.egressActive = true;
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
|
||||
config: { secrets: { egressProxy: { enabled: true } } },
|
||||
});
|
||||
await tool.execute("call-egress-enabled", {
|
||||
command: "echo ok",
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
|
||||
const env = mocks.gatewayParams[0]?.env ?? {};
|
||||
expect(env.AWS_REGION).toBe("us-west-2");
|
||||
expect(looksLikeSecretSentinel(env.SERVICE_API_KEY ?? "")).toBe(true);
|
||||
expect(resolveSecretSentinel(env.SERVICE_API_KEY ?? "")).toBe("enabled-secret");
|
||||
expect(env).toMatchObject({
|
||||
HTTPS_PROXY: mocks.proxyUrl,
|
||||
HTTP_PROXY: mocks.proxyUrl,
|
||||
NODE_EXTRA_CA_CERTS: "/state/secret-egress/root-ca.pem",
|
||||
SSL_CERT_FILE: "/state/secret-egress/root-ca.pem",
|
||||
CURL_CA_BUNDLE: "/state/secret-egress/root-ca.pem",
|
||||
REQUESTS_CA_BUNDLE: "/state/secret-egress/root-ca.pem",
|
||||
});
|
||||
expect(JSON.stringify(env)).not.toContain("enabled-secret");
|
||||
expect(mocks.proxyBindings).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
name: "SERVICE_API_KEY",
|
||||
allowedHosts: ["api.example.com"],
|
||||
sentinel: env.SERVICE_API_KEY,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +249,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
: undefined,
|
||||
sessionId: attempt.sessionId,
|
||||
runId: attempt.runId,
|
||||
operationalRunInstance: attempt.admittedRunContext.operationalRunInstance,
|
||||
conversationRecall: attempt.conversationRecall,
|
||||
approvalReviewerDeviceId: attempt.approvalReviewerDeviceId,
|
||||
oneShotCliRun: attempt.oneShotCliRun,
|
||||
|
||||
@@ -12,6 +12,7 @@ const mocks = await vi.hoisted(async () => {
|
||||
list: vi.fn(),
|
||||
read: vi.fn(),
|
||||
write: vi.fn(),
|
||||
updateHosts: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
gatewayIdentity: vi.fn(),
|
||||
@@ -26,10 +27,14 @@ vi.mock("../secrets/store/secret-store.js", async (importOriginal) => ({
|
||||
SecretStoreValidationError: (
|
||||
await importOriginal<typeof import("../secrets/store/secret-store.js")>()
|
||||
).SecretStoreValidationError,
|
||||
normalizeSecretAllowedHosts: (
|
||||
await importOriginal<typeof import("../secrets/store/secret-store.js")>()
|
||||
).normalizeSecretAllowedHosts,
|
||||
SECRET_STORE_VALUE_MAX_BYTES: 64 * 1024,
|
||||
listSecretStoreEntries: (params: unknown) => mocks.list(params),
|
||||
readSecretStoreValue: (params: unknown) => mocks.read(params),
|
||||
writeSecretStoreEntry: (params: unknown) => mocks.write(params),
|
||||
updateSecretStoreAllowedHosts: (params: unknown) => mocks.updateHosts(params),
|
||||
deleteSecretStoreEntry: (params: unknown) => mocks.remove(params),
|
||||
purgeExpiredSecretStoreEntries: () => mocks.purge(),
|
||||
}));
|
||||
@@ -55,9 +60,10 @@ function createProgram(): Command {
|
||||
beforeEach(() => {
|
||||
mocks.runtimeLogs.length = 0;
|
||||
mocks.runtimeErrors.length = 0;
|
||||
mocks.list.mockReset();
|
||||
mocks.list.mockReset().mockReturnValue([]);
|
||||
mocks.read.mockReset();
|
||||
mocks.write.mockReset();
|
||||
mocks.updateHosts.mockReset();
|
||||
mocks.remove.mockReset();
|
||||
mocks.purge.mockReset();
|
||||
mocks.gatewayIdentity.mockReset().mockResolvedValue(undefined);
|
||||
@@ -70,6 +76,22 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("secrets store CLI", () => {
|
||||
it("shows non-secret allowed-host metadata in list output", async () => {
|
||||
mocks.list.mockReturnValue([
|
||||
{
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com", "uploads.example.com"],
|
||||
},
|
||||
]);
|
||||
|
||||
await createProgram().parseAsync(["secrets", "store", "list"], { from: "user" });
|
||||
|
||||
expect(mocks.runtimeLogs.join("\n")).toContain(
|
||||
"allowed hosts: api.example.com, uploads.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses --value for secret entries with all safe alternatives and exit 2", async () => {
|
||||
await expect(
|
||||
createProgram().parseAsync(
|
||||
@@ -115,6 +137,55 @@ describe("secrets store CLI", () => {
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes repeatable allowed hosts and can clear them without replacing the secret", async () => {
|
||||
mocks.list.mockReturnValue([{ name: "MISC_VALUE", kind: "secret" }]);
|
||||
|
||||
await createProgram().parseAsync(
|
||||
[
|
||||
"secrets",
|
||||
"store",
|
||||
"set",
|
||||
"MISC_VALUE",
|
||||
"--allow-host",
|
||||
"API.EXAMPLE.COM",
|
||||
"--allow-host",
|
||||
"bücher.example",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
await createProgram().parseAsync(
|
||||
["secrets", "store", "set", "MISC_VALUE", "--clear-allowed-hosts"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.updateHosts).toHaveBeenNthCalledWith(1, {
|
||||
scope: { kind: "team" },
|
||||
name: "MISC_VALUE",
|
||||
allowedHosts: ["api.example.com", "xn--bcher-kva.example"],
|
||||
updatedBy: "cli",
|
||||
});
|
||||
expect(mocks.updateHosts).toHaveBeenNthCalledWith(2, {
|
||||
scope: { kind: "team" },
|
||||
name: "MISC_VALUE",
|
||||
allowedHosts: [],
|
||||
updatedBy: "cli",
|
||||
});
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects wildcard allowed hosts before reading or writing a value", async () => {
|
||||
await expect(
|
||||
createProgram().parseAsync(
|
||||
["secrets", "store", "set", "SERVICE_API_KEY", "--allow-host", "*.example.com"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("__exit__:2");
|
||||
|
||||
expect(mocks.runtimeErrors.join("\n")).toContain("cannot contain a wildcard");
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
expect(mocks.updateHosts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns exit 3 for a missing get and exit 1 for a database failure", async () => {
|
||||
mocks.list.mockReturnValueOnce([]);
|
||||
await expect(
|
||||
|
||||
@@ -18,6 +18,8 @@ type SetOptions = {
|
||||
kind?: string;
|
||||
scope?: string;
|
||||
dryRun?: boolean;
|
||||
allowHost?: string[];
|
||||
clearAllowedHosts?: boolean;
|
||||
};
|
||||
type RemoveOptions = { scope?: string; dryRun?: boolean; yes?: boolean };
|
||||
type ImportOptions = RemoveOptions & { from?: string; kind?: string };
|
||||
@@ -74,7 +76,8 @@ function mapStoreError(error: unknown): SecretStoreCliFailure {
|
||||
validation?.name === "SecretStoreValidationError" &&
|
||||
(validation.code === "SECRET_STORE_INVALID_NAME" ||
|
||||
validation.code === "SECRET_STORE_VALUE_TOO_LARGE" ||
|
||||
validation.code === "SECRET_STORE_VALUE_EMPTY")
|
||||
validation.code === "SECRET_STORE_VALUE_EMPTY" ||
|
||||
validation.code === "SECRET_STORE_INVALID_ALLOWED_HOST")
|
||||
) {
|
||||
return new SecretStoreCliFailure(2, validation.message ?? "Invalid secret store input.");
|
||||
}
|
||||
@@ -103,7 +106,12 @@ function renderList(entries: SecretStoreEntryMetadata[], options: OutputOptions)
|
||||
if (options.plain) {
|
||||
for (const entry of entries) {
|
||||
defaultRuntime.writeStdout(
|
||||
[entry.name, entry.kind, entry.kind === "env" ? (entry.valuePreview ?? "") : ""].join("\t"),
|
||||
[
|
||||
entry.name,
|
||||
entry.kind,
|
||||
entry.kind === "env" ? (entry.valuePreview ?? "") : "",
|
||||
entry.kind === "secret" ? (entry.allowedHosts ?? []).join(",") : "",
|
||||
].join("\t"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -114,7 +122,11 @@ function renderList(entries: SecretStoreEntryMetadata[], options: OutputOptions)
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const value = entry.kind === "env" ? ` = ${entry.valuePreview ?? ""}` : " (write-only)";
|
||||
defaultRuntime.log(`${entry.name} [${entry.kind}]${value}`);
|
||||
const hosts =
|
||||
entry.kind === "secret"
|
||||
? `; allowed hosts: ${(entry.allowedHosts ?? []).join(", ") || "none"}`
|
||||
: "";
|
||||
defaultRuntime.log(`${entry.name} [${entry.kind}]${value}${hosts}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +188,43 @@ export function registerSecretStoreCli(secrets: Command): void {
|
||||
.option("--value <value>", "Literal value (env kind only)")
|
||||
.option("--value-file <path>", "Read value from a file; use - for stdin")
|
||||
.option("--kind <secret|env>", "Entry kind (defaults from NAME)")
|
||||
.option(
|
||||
"--allow-host <host>",
|
||||
"Allow substitution only for this exact host (repeatable)",
|
||||
(host: string, hosts: string[]) => [...hosts, host],
|
||||
[],
|
||||
)
|
||||
.option("--clear-allowed-hosts", "Remove all allowed hosts", false)
|
||||
.option("--scope <team>", "Store scope", "team")
|
||||
.option("--dry-run", "Validate without writing", false)
|
||||
.action((name: string, options: SetOptions) =>
|
||||
runStoreAction(async () => {
|
||||
assertStoreName(name);
|
||||
const scope = teamScope(options.scope);
|
||||
const kind = storeKind(options.kind, name);
|
||||
const storeModule = await import("../secrets/store/secret-store.js");
|
||||
const requestedHosts = options.allowHost ?? [];
|
||||
const hostPolicyRequested = requestedHosts.length > 0 || options.clearAllowedHosts === true;
|
||||
const existingEntry = hostPolicyRequested
|
||||
? storeModule.listSecretStoreEntries({ scope }).find((entry) => entry.name === name)
|
||||
: undefined;
|
||||
const kind = options.kind
|
||||
? storeKind(options.kind, name)
|
||||
: (existingEntry?.kind ?? storeKind(undefined, name));
|
||||
if (requestedHosts.length > 0 && options.clearAllowedHosts) {
|
||||
throw new SecretStoreCliFailure(
|
||||
2,
|
||||
"Use either --allow-host or --clear-allowed-hosts, not both.",
|
||||
);
|
||||
}
|
||||
if (kind === "env" && (requestedHosts.length > 0 || options.clearAllowedHosts)) {
|
||||
throw new SecretStoreCliFailure(2, "Allowed hosts apply only to secret entries.");
|
||||
}
|
||||
const allowedHosts =
|
||||
requestedHosts.length > 0
|
||||
? storeModule.normalizeSecretAllowedHosts(requestedHosts)
|
||||
: options.clearAllowedHosts
|
||||
? []
|
||||
: undefined;
|
||||
if (options.value !== undefined && options.valueFile !== undefined) {
|
||||
throw new SecretStoreCliFailure(2, "Use only one of --value or --value-file.");
|
||||
}
|
||||
@@ -193,6 +235,29 @@ export function registerSecretStoreCli(secrets: Command): void {
|
||||
"--value is refused for secret entries. Use a stdin pipe, --value-file, or the interactive no-echo prompt.",
|
||||
);
|
||||
}
|
||||
const policyOnly =
|
||||
allowedHosts !== undefined &&
|
||||
options.value === undefined &&
|
||||
options.valueFile === undefined &&
|
||||
existingEntry?.kind === "secret";
|
||||
if (policyOnly) {
|
||||
if (options.dryRun) {
|
||||
defaultRuntime.log(`Would update allowed hosts for ${name}.`);
|
||||
return;
|
||||
}
|
||||
storeModule.updateSecretStoreAllowedHosts({
|
||||
scope,
|
||||
name,
|
||||
allowedHosts,
|
||||
updatedBy: "cli",
|
||||
});
|
||||
defaultRuntime.log(
|
||||
allowedHosts.length > 0
|
||||
? `Allowed ${name} for ${allowedHosts.join(", ")}.`
|
||||
: `Cleared allowed hosts for ${name}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const value =
|
||||
options.value !== undefined
|
||||
? options.value
|
||||
@@ -201,7 +266,6 @@ export function registerSecretStoreCli(secrets: Command): void {
|
||||
).readSecretStoreInput({
|
||||
valueFile: options.valueFile,
|
||||
});
|
||||
const storeModule = await import("../secrets/store/secret-store.js");
|
||||
if (Buffer.byteLength(value, "utf8") > storeModule.SECRET_STORE_VALUE_MAX_BYTES) {
|
||||
throw new SecretStoreCliFailure(
|
||||
2,
|
||||
@@ -212,7 +276,14 @@ export function registerSecretStoreCli(secrets: Command): void {
|
||||
defaultRuntime.log(`Would ${kind === "secret" ? "write" : "set"} ${name} (${kind}).`);
|
||||
return;
|
||||
}
|
||||
storeModule.writeSecretStoreEntry({ scope, name, value, kind, updatedBy: "cli" });
|
||||
storeModule.writeSecretStoreEntry({
|
||||
scope,
|
||||
name,
|
||||
value,
|
||||
kind,
|
||||
...(allowedHosts !== undefined ? { allowedHosts } : {}),
|
||||
updatedBy: "cli",
|
||||
});
|
||||
storeModule.purgeExpiredSecretStoreEntries();
|
||||
defaultRuntime.log(`Stored ${name} (${kind}).`);
|
||||
await noteGatewayReload();
|
||||
|
||||
@@ -24,6 +24,10 @@ describe("config secret refs schema", () => {
|
||||
it("accepts top-level secrets sources and model apiKey refs", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
secrets: {
|
||||
egressProxy: {
|
||||
enabled: true,
|
||||
bypassHosts: ["pinned.example.com"],
|
||||
},
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
filemain: {
|
||||
@@ -58,6 +62,20 @@ describe("config secret refs schema", () => {
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.config.secrets?.egressProxy).toEqual({
|
||||
enabled: true,
|
||||
bypassHosts: ["pinned.example.com"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty secret egress bypass hosts", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
secrets: { egressProxy: { enabled: false, bypassHosts: [""] } },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects store refs outside the env-name grammar", () => {
|
||||
|
||||
@@ -24,6 +24,14 @@ export const CORE_FIELD_HELP: Record<string, string> = {
|
||||
"Maximum time in milliseconds allowed for shell environment resolution before fallback behavior applies. Use tighter timeouts for faster startup, or increase when shell initialization is heavy.",
|
||||
"env.vars":
|
||||
"Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.",
|
||||
secrets:
|
||||
"Secret reference providers, shared-store behavior, and optional subprocess egress protection.",
|
||||
"secrets.egressProxy":
|
||||
"Gateway-owned loopback proxy that replaces shared-store secret sentinels only at outbound request time. Restart the Gateway after changing this startup-scoped section.",
|
||||
"secrets.egressProxy.enabled":
|
||||
"Enables secret egress substitution for Gateway-hosted agent subprocesses. Default: false.",
|
||||
"secrets.egressProxy.bypassHosts":
|
||||
"Exact hostnames that use authenticated blind CONNECT tunnels for certificate-pinned clients. Sentinels remain ciphertext and will fail vendor authentication instead of exposing plaintext.",
|
||||
wizard:
|
||||
"User-owned setup preferences. Machine-owned wizard history and acknowledgement state live in the shared state database.",
|
||||
"wizard.accessMode":
|
||||
|
||||
@@ -17,6 +17,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"env.shellEnv.enabled": "Shell Environment Import Enabled",
|
||||
"env.shellEnv.timeoutMs": "Shell Environment Import Timeout (ms)",
|
||||
"env.vars": "Environment Variable Overrides",
|
||||
secrets: "Secrets",
|
||||
"secrets.egressProxy": "Secret Egress Proxy",
|
||||
"secrets.egressProxy.enabled": "Secret Egress Proxy Enabled",
|
||||
"secrets.egressProxy.bypassHosts": "Secret Egress Proxy Bypass Hosts",
|
||||
wizard: "Setup Preferences",
|
||||
"wizard.accessMode": "Setup Discovery Access",
|
||||
"wizard.appRecommendations": "Setup App Recommendations",
|
||||
|
||||
@@ -381,6 +381,10 @@ export type SecretProviderConfig =
|
||||
| StoreSecretProviderConfig;
|
||||
|
||||
export type SecretsConfig = {
|
||||
egressProxy?: {
|
||||
enabled?: boolean;
|
||||
bypassHosts?: string[];
|
||||
};
|
||||
providers?: Record<string, SecretProviderConfig>;
|
||||
defaults?: {
|
||||
env?: string;
|
||||
|
||||
@@ -204,6 +204,13 @@ export const SecretProviderSchema = z.union([
|
||||
/** Schema for the top-level `secrets` config block. */
|
||||
export const SecretsConfigSchema = z
|
||||
.object({
|
||||
egressProxy: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
bypassHosts: z.array(z.string().trim().min(1)).max(256).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
providers: z
|
||||
.object({
|
||||
// Keep this as a record so users can define multiple named providers per source.
|
||||
|
||||
@@ -127,6 +127,8 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
|
||||
// startup; disposing MCP runtimes cannot move or create that HTTP server.
|
||||
{ prefix: "mcp.apps", kind: "restart" },
|
||||
{ prefix: "mcp", kind: "hot", actions: ["dispose-mcp-runtimes"] },
|
||||
// The proxy listener, per-start CA, and run-token registry are Gateway-owned.
|
||||
{ prefix: "secrets.egressProxy", kind: "restart" },
|
||||
{ prefix: "plugins.load", kind: "restart" },
|
||||
{ prefix: "plugins.installs", kind: "restart" },
|
||||
];
|
||||
|
||||
@@ -288,6 +288,8 @@ describe("buildGatewayReloadPlan", () => {
|
||||
"plugins.installs.telegram.installPath",
|
||||
"plugins.load.paths.0",
|
||||
"gateway.auth.mode",
|
||||
"secrets.egressProxy.enabled",
|
||||
"secrets.egressProxy.bypassHosts",
|
||||
])("keeps restart-owned path restart-backed: %s", (path) => {
|
||||
const plan = buildGatewayReloadPlan([path]);
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ type SecretsReloadHarnessParams = {
|
||||
logChannelsInfo?: GatewayAuxHandlerParams["logChannels"]["info"];
|
||||
respond?: ReturnType<typeof vi.fn>;
|
||||
onApprovalLifecycle?: GatewayAuxHandlerParams["onApprovalLifecycle"];
|
||||
onAgentRunAuthorityClosed?: GatewayAuxHandlerParams["onAgentRunAuthorityClosed"];
|
||||
validateAgentRuntimeDelegatedAuthority?: GatewayAuxHandlerParams["validateAgentRuntimeDelegatedAuthority"];
|
||||
registerWorkerTurnClaimClosedHandler?: GatewayAuxHandlerParams["registerWorkerTurnClaimClosedHandler"];
|
||||
};
|
||||
@@ -236,6 +237,7 @@ function createSecretsReloadHarness(params: SecretsReloadHarnessParams) {
|
||||
getChannelAutostartSuppression: params.getChannelAutostartSuppression,
|
||||
logChannels: { info: params.logChannelsInfo ?? vi.fn() },
|
||||
onApprovalLifecycle: params.onApprovalLifecycle,
|
||||
onAgentRunAuthorityClosed: params.onAgentRunAuthorityClosed,
|
||||
validateAgentRuntimeDelegatedAuthority: params.validateAgentRuntimeDelegatedAuthority,
|
||||
registerWorkerTurnClaimClosedHandler: params.registerWorkerTurnClaimClosedHandler,
|
||||
});
|
||||
@@ -302,6 +304,27 @@ describe("gateway aux handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fans exact run closure out to Gateway-owned capability cleanup", () => {
|
||||
const onAgentRunAuthorityClosed = vi.fn();
|
||||
const gatewayAux = createSecretsReloadHarness({
|
||||
activateRuntimeSecrets: mockResolvedSecrets(asConfig({})),
|
||||
onAgentRunAuthorityClosed,
|
||||
});
|
||||
const operationalRunInstance = Object.freeze({
|
||||
instanceId: "egress-proxy-instance",
|
||||
runId: "egress-proxy-run",
|
||||
});
|
||||
const authority = claimAgentRunDelegatedAuthority(operationalRunInstance);
|
||||
|
||||
releaseAgentRunDelegatedAuthority(authority);
|
||||
|
||||
expect(onAgentRunAuthorityClosed).toHaveBeenCalledOnce();
|
||||
expect(onAgentRunAuthorityClosed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ operationalRunInstance }),
|
||||
);
|
||||
gatewayAux.unregisterApprovalAuthorityObserver();
|
||||
});
|
||||
|
||||
it("settles and publishes both approval kinds from the production worker-claim observer", async () => {
|
||||
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "openclaw-aux-worker-"));
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// Wires reload, secrets, exec approval, and plugin approval RPC handlers.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { registerAgentRunDelegatedAuthorityClosedHandler } from "../infra/agent-run-registry.js";
|
||||
import {
|
||||
type AgentRunDelegatedAuthority,
|
||||
registerAgentRunDelegatedAuthorityClosedHandler,
|
||||
} from "../infra/agent-run-registry.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
||||
import {
|
||||
@@ -127,6 +130,7 @@ export function createGatewayAuxHandlers(params: {
|
||||
getChannelAutostartSuppression?: () => ChannelAutostartSuppression | null;
|
||||
logChannels: { info: (msg: string) => void };
|
||||
onApprovalLifecycle?: (event: OperatorApprovalLifecycleEvent) => void;
|
||||
onAgentRunAuthorityClosed?: (authority: AgentRunDelegatedAuthority) => void;
|
||||
validateAgentRuntimeDelegatedAuthority?: (authority: AgentRuntimeDelegatedAuthority) => boolean;
|
||||
chatAbortControllers?: Map<string, ChatAbortControllerEntry>;
|
||||
registerWorkerTurnClaimClosedHandler?: (
|
||||
@@ -248,6 +252,7 @@ export function createGatewayAuxHandlers(params: {
|
||||
} catch (error) {
|
||||
params.log.error?.(`plugin approvals: authority-close settlement failed: ${String(error)}`);
|
||||
}
|
||||
params.onAgentRunAuthorityClosed?.(authority);
|
||||
},
|
||||
);
|
||||
const unregisterWorkerTurnClaimClosedObserver = params.registerWorkerTurnClaimClosedHandler?.(
|
||||
|
||||
@@ -155,6 +155,19 @@ export async function startGatewayCoreRuntime(input: {
|
||||
if (desktopSessionRegistry) {
|
||||
kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() });
|
||||
}
|
||||
const secretEgressProxy =
|
||||
cfgAtStart.secrets?.egressProxy?.enabled === true
|
||||
? await import("../secrets/egress-proxy/runtime.js").then((egressRuntime) =>
|
||||
egressRuntime.startGatewaySecretEgressProxy(
|
||||
cfgAtStart.secrets?.egressProxy?.bypassHosts
|
||||
? { bypassHosts: cfgAtStart.secrets.egressProxy.bypassHosts }
|
||||
: {},
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
if (secretEgressProxy) {
|
||||
kernel.addGatewayLifetimeSidecar(secretEgressProxy);
|
||||
}
|
||||
let earlyRuntimePromise: ReturnType<
|
||||
Awaited<ReturnType<typeof loadGatewayStartupEarlyModule>>["startGatewayEarlyRuntime"]
|
||||
> | null = null;
|
||||
@@ -339,6 +352,9 @@ export async function startGatewayCoreRuntime(input: {
|
||||
delegatedAuthority: authority,
|
||||
}),
|
||||
onApprovalLifecycle: approvalSessionEvents.publish,
|
||||
onAgentRunAuthorityClosed: (authority) => {
|
||||
secretEgressProxy?.revokeRun(authority.operationalRunInstance);
|
||||
},
|
||||
}),
|
||||
coreGatewayHandlers: coreGatewayHandlersLocal,
|
||||
};
|
||||
|
||||
@@ -366,6 +366,7 @@ describe("secrets handlers", () => {
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
updatedBy: "Operator",
|
||||
allowedHosts: ["api.example.com"],
|
||||
valuePreview: "malicious-leak",
|
||||
},
|
||||
{
|
||||
@@ -388,7 +389,7 @@ describe("secrets handlers", () => {
|
||||
});
|
||||
expect(respond.mock.calls[0]?.[1]).toMatchObject({
|
||||
entries: [
|
||||
{ name: "SERVICE_API_KEY", kind: "secret" },
|
||||
{ name: "SERVICE_API_KEY", kind: "secret", allowedHosts: ["api.example.com"] },
|
||||
{ name: "SERVICE_URL", kind: "env", value: "https://service.test" },
|
||||
],
|
||||
});
|
||||
@@ -417,7 +418,12 @@ describe("secrets handlers", () => {
|
||||
await invokeStoreMethod({
|
||||
handlers,
|
||||
method: "secrets.store.set",
|
||||
requestParams: { name: "SERVICE_API_KEY", value: "new-value", kind: "secret" },
|
||||
requestParams: {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "new-value",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com"],
|
||||
},
|
||||
respond: setRespond,
|
||||
});
|
||||
expect(storeMocks.writeEntry).toHaveBeenCalledWith({
|
||||
@@ -425,6 +431,7 @@ describe("secrets handlers", () => {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "new-value",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com"],
|
||||
updatedBy: "Control UI",
|
||||
});
|
||||
expect(setRespond).toHaveBeenCalledWith(true, {
|
||||
|
||||
@@ -48,7 +48,7 @@ function toProtocolStoreEntry(
|
||||
}
|
||||
return { ...metadata, kind: "env", value: entry.valuePreview };
|
||||
}
|
||||
return { ...metadata, kind: "secret" };
|
||||
return { ...metadata, kind: "secret", allowedHosts: entry.allowedHosts ?? [] };
|
||||
}
|
||||
|
||||
function storeUpdatedBy(client: GatewayClient | null): string {
|
||||
@@ -285,6 +285,9 @@ export function createSecretsHandlers(params: {
|
||||
name: requestParams.name,
|
||||
value: requestParams.value,
|
||||
kind: requestParams.kind,
|
||||
...(requestParams.allowedHosts !== undefined
|
||||
? { allowedHosts: requestParams.allowedHosts }
|
||||
: {}),
|
||||
updatedBy: storeUpdatedBy(client),
|
||||
});
|
||||
stored = true;
|
||||
|
||||
+159
-24
@@ -1,26 +1,33 @@
|
||||
// Proxy capture CA helpers create and inspect local capture CA certificates.
|
||||
import { createPrivateKey, X509Certificate } from "node:crypto";
|
||||
import { createHash, createPrivateKey, randomBytes, X509Certificate } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseCanonicalIpAddress } from "@openclaw/net-policy/ip";
|
||||
import { type FileLockOptions, withFileLock } from "../infra/file-lock.js";
|
||||
import { resolveSystemBin } from "../infra/resolve-system-bin.js";
|
||||
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
|
||||
const DEBUG_PROXY_CA_GENERATION_TIMEOUT_MS = 30_000;
|
||||
const DEBUG_PROXY_CA_OPENSSL_CONFIG = [
|
||||
"[req]",
|
||||
"distinguished_name = subject",
|
||||
"prompt = no",
|
||||
"",
|
||||
"[subject]",
|
||||
"CN = OpenClaw Debug Proxy",
|
||||
"",
|
||||
"[v3_ca]",
|
||||
"basicConstraints = critical, CA:TRUE",
|
||||
"keyUsage = critical, keyCertSign, cRLSign",
|
||||
"",
|
||||
].join("\n");
|
||||
const LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS = 30_000;
|
||||
const LOCAL_PROXY_DIR_MODE = 0o700;
|
||||
const LOCAL_PROXY_PRIVATE_KEY_MODE = 0o600;
|
||||
|
||||
function buildLocalProxyCaOpenSslConfig(commonName: string): string {
|
||||
return [
|
||||
"[req]",
|
||||
"distinguished_name = subject",
|
||||
"prompt = no",
|
||||
"",
|
||||
"[subject]",
|
||||
`CN = ${commonName}`,
|
||||
"",
|
||||
"[v3_ca]",
|
||||
"basicConstraints = critical, CA:TRUE",
|
||||
"keyUsage = critical, keyCertSign, cRLSign",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
const DEBUG_PROXY_CA_LOCK_OPTIONS: FileLockOptions = {
|
||||
retries: {
|
||||
// About 36s of minimum backoff covers one full 30s OpenSSL deadline.
|
||||
@@ -58,13 +65,23 @@ function removeStagingDirBestEffort(stagingDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs
|
||||
// are reused within the cert dir so repeated starts do not prompt regeneration.
|
||||
export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
type LocalProxyCaOptions = {
|
||||
commonName: string;
|
||||
purpose: string;
|
||||
validityDays: number;
|
||||
};
|
||||
|
||||
type LocalProxyCaPair = {
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
}> {
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
};
|
||||
|
||||
async function ensureLocalProxyCa(
|
||||
certDir: string,
|
||||
options: LocalProxyCaOptions,
|
||||
): Promise<LocalProxyCaPair> {
|
||||
fs.mkdirSync(certDir, { recursive: true, mode: LOCAL_PROXY_DIR_MODE });
|
||||
fs.chmodSync(certDir, LOCAL_PROXY_DIR_MODE);
|
||||
const certPath = path.join(certDir, "root-ca.pem");
|
||||
const keyPath = path.join(certDir, "root-ca-key.pem");
|
||||
const canonicalKeyPath = path.join(fs.realpathSync(certDir), "root-ca-key.pem");
|
||||
@@ -75,14 +92,16 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
}
|
||||
const openssl = resolveSystemBin("openssl");
|
||||
if (!openssl) {
|
||||
throw new Error("openssl is required to generate debug proxy certificates");
|
||||
throw new Error(`openssl is required to generate ${options.purpose} certificates`);
|
||||
}
|
||||
const stagingDir = fs.mkdtempSync(path.join(certDir, ".root-ca-"));
|
||||
const stagedConfigPath = path.join(stagingDir, "openssl.cnf");
|
||||
const stagedCertPath = path.join(stagingDir, "root-ca.pem");
|
||||
const stagedKeyPath = path.join(stagingDir, "root-ca-key.pem");
|
||||
try {
|
||||
fs.writeFileSync(stagedConfigPath, DEBUG_PROXY_CA_OPENSSL_CONFIG, { mode: 0o600 });
|
||||
fs.writeFileSync(stagedConfigPath, buildLocalProxyCaOpenSslConfig(options.commonName), {
|
||||
mode: LOCAL_PROXY_PRIVATE_KEY_MODE,
|
||||
});
|
||||
await runExec(
|
||||
openssl,
|
||||
[
|
||||
@@ -96,7 +115,7 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
"rsa:2048",
|
||||
"-sha256",
|
||||
"-days",
|
||||
"7",
|
||||
String(options.validityDays),
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
stagedKeyPath,
|
||||
@@ -106,9 +125,9 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
{ logOutput: false, timeoutMs: DEBUG_PROXY_CA_GENERATION_TIMEOUT_MS },
|
||||
);
|
||||
if (!isValidDebugProxyCaPair(stagedCertPath, stagedKeyPath)) {
|
||||
throw new Error("openssl generated invalid debug proxy certificate material");
|
||||
throw new Error(`openssl generated invalid ${options.purpose} certificate material`);
|
||||
}
|
||||
fs.chmodSync(stagedKeyPath, 0o600);
|
||||
fs.chmodSync(stagedKeyPath, LOCAL_PROXY_PRIVATE_KEY_MODE);
|
||||
fs.chmodSync(stagedCertPath, 0o644);
|
||||
// All OpenClaw writers hold this lock. Same-directory renames replace each
|
||||
// file atomically; validation repairs a pair interrupted between renames.
|
||||
@@ -121,3 +140,119 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs
|
||||
// are reused within the cert dir so repeated starts do not prompt regeneration.
|
||||
export async function ensureDebugProxyCa(certDir: string): Promise<{
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
}> {
|
||||
return await ensureLocalProxyCa(certDir, {
|
||||
commonName: "OpenClaw Debug Proxy",
|
||||
purpose: "debug proxy",
|
||||
validityDays: 7,
|
||||
});
|
||||
}
|
||||
|
||||
/** Generates the root CA for one Gateway-lifetime secret egress proxy. */
|
||||
export async function ensureSecretEgressProxyCa(certDir: string): Promise<LocalProxyCaPair> {
|
||||
return await ensureLocalProxyCa(certDir, {
|
||||
commonName: "OpenClaw Secret Egress Proxy",
|
||||
purpose: "secret egress proxy",
|
||||
validityDays: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function isValidLeafPair(params: { certPath: string; keyPath: string; hostname: string }): boolean {
|
||||
try {
|
||||
const cert = new X509Certificate(fs.readFileSync(params.certPath));
|
||||
const key = createPrivateKey(fs.readFileSync(params.keyPath));
|
||||
const hostMatches = parseCanonicalIpAddress(params.hostname)
|
||||
? cert.checkIP(params.hostname) === params.hostname
|
||||
: cert.checkHost(params.hostname) === params.hostname;
|
||||
return !cert.ca && cert.checkPrivateKey(key) && hostMatches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLocalProxyLeafQueued(params: {
|
||||
certDir: string;
|
||||
ca: LocalProxyCaPair;
|
||||
hostname: string;
|
||||
}): Promise<{ cert: Buffer; key: Buffer }> {
|
||||
const openssl = resolveSystemBin("openssl");
|
||||
if (!openssl) {
|
||||
throw new Error("openssl is required to generate local proxy certificates");
|
||||
}
|
||||
const leafKeyPath = path.join(params.certDir, "leaf-key.pem");
|
||||
if (!fs.existsSync(leafKeyPath)) {
|
||||
await runExec(openssl, ["genrsa", "-out", leafKeyPath, "2048"], {
|
||||
logOutput: false,
|
||||
timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
fs.chmodSync(leafKeyPath, LOCAL_PROXY_PRIVATE_KEY_MODE);
|
||||
}
|
||||
const leafId = createHash("sha256").update(params.hostname).digest("hex");
|
||||
const stagingDir = fs.mkdtempSync(path.join(params.certDir, `.leaf-${leafId.slice(0, 12)}-`));
|
||||
const csrPath = path.join(stagingDir, "leaf.csr");
|
||||
const certPath = path.join(stagingDir, "leaf.pem");
|
||||
const extPath = path.join(stagingDir, "leaf.ext");
|
||||
try {
|
||||
const sanKind = parseCanonicalIpAddress(params.hostname) ? "IP" : "DNS";
|
||||
fs.writeFileSync(
|
||||
extPath,
|
||||
`subjectAltName=${sanKind}:${params.hostname}\nextendedKeyUsage=serverAuth\n`,
|
||||
{ mode: LOCAL_PROXY_PRIVATE_KEY_MODE },
|
||||
);
|
||||
await runExec(
|
||||
openssl,
|
||||
["req", "-new", "-key", leafKeyPath, "-subj", `/CN=${params.hostname}`, "-out", csrPath],
|
||||
{ logOutput: false, timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS },
|
||||
);
|
||||
await runExec(
|
||||
openssl,
|
||||
[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csrPath,
|
||||
"-CA",
|
||||
params.ca.certPath,
|
||||
"-CAkey",
|
||||
params.ca.keyPath,
|
||||
"-set_serial",
|
||||
`0x${randomBytes(16).toString("hex")}`,
|
||||
"-out",
|
||||
certPath,
|
||||
"-days",
|
||||
"1",
|
||||
"-sha256",
|
||||
"-extfile",
|
||||
extPath,
|
||||
],
|
||||
{ logOutput: false, timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS },
|
||||
);
|
||||
if (!isValidLeafPair({ certPath, keyPath: leafKeyPath, hostname: params.hostname })) {
|
||||
throw new Error("openssl generated invalid local proxy leaf certificate material");
|
||||
}
|
||||
return {
|
||||
cert: fs.readFileSync(certPath),
|
||||
key: fs.readFileSync(leafKeyPath),
|
||||
};
|
||||
} finally {
|
||||
removeStagingDirBestEffort(stagingDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mints one on-demand TLS leaf signed by a local proxy CA. */
|
||||
export async function generateLocalProxyLeaf(params: {
|
||||
certDir: string;
|
||||
ca: LocalProxyCaPair;
|
||||
hostname: string;
|
||||
}): Promise<{ cert: Buffer; key: Buffer }> {
|
||||
const queueKey = path.join(fs.realpathSync(params.certDir), "leaf-key.pem");
|
||||
return await debugProxyCaGenerationQueue.enqueue(queueKey, () =>
|
||||
generateLocalProxyLeafQueued(params),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import fs from "node:fs";
|
||||
import { request as httpRequest, type Server } from "node:http";
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import net, { type Socket } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { generateLocalProxyLeaf } from "../../proxy-capture/ca.js";
|
||||
import {
|
||||
mintSecretSentinel,
|
||||
SECRET_SENTINEL_MAX_LENGTH,
|
||||
SECRET_SENTINEL_PREFIX,
|
||||
} from "../sentinel.js";
|
||||
import { startSecretEgressProxyServer, type SecretEgressProxyHandle } from "./proxy-server.js";
|
||||
|
||||
type SecretEgressProxyAuditEvent = Parameters<
|
||||
typeof startSecretEgressProxyServer
|
||||
>[0]["onAudit"] extends (event: infer Event) => void
|
||||
? Event
|
||||
: never;
|
||||
|
||||
type OriginRequest = {
|
||||
body: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const servers: Server[] = [];
|
||||
const proxies: SecretEgressProxyHandle[] = [];
|
||||
const sockets = new Set<Socket>();
|
||||
const tempDirs: string[] = [];
|
||||
let caDir: string;
|
||||
let auditEvents: SecretEgressProxyAuditEvent[];
|
||||
let originRequests: OriginRequest[];
|
||||
let originPort: number;
|
||||
let proxy: SecretEgressProxyHandle;
|
||||
let run: Readonly<{ instanceId: string; runId: string }>;
|
||||
let proxyEnv: Record<string, string>;
|
||||
|
||||
function registerSentinel(params: {
|
||||
sentinel: string;
|
||||
allowedHosts: readonly string[];
|
||||
name?: string;
|
||||
targetProxy?: SecretEgressProxyHandle;
|
||||
}): Record<string, string> {
|
||||
return (params.targetProxy ?? proxy).registerRun(run, [
|
||||
{
|
||||
name: params.name ?? "SERVICE_API_KEY",
|
||||
sentinel: params.sentinel,
|
||||
allowedHosts: params.allowedHosts,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<number> {
|
||||
servers.push(server);
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("test server did not bind a TCP port"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function basicProxyAuth(password: string): string {
|
||||
return `Basic ${Buffer.from(`openclaw:${password}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
function registeredPassword(env: Record<string, string>): string {
|
||||
const proxyUrl = env.HTTPS_PROXY;
|
||||
if (!proxyUrl) {
|
||||
throw new Error("test proxy environment is missing HTTPS_PROXY");
|
||||
}
|
||||
return new URL(proxyUrl).password;
|
||||
}
|
||||
|
||||
async function rawConnect(params: {
|
||||
auth?: string;
|
||||
proxyOrigin?: string;
|
||||
}): Promise<{ response: string; socket: Socket }> {
|
||||
const proxyUrl = new URL(params.proxyOrigin ?? proxy.proxyOrigin);
|
||||
const socket = net.connect(Number(proxyUrl.port), proxyUrl.hostname);
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const authLine = params.auth ? `Proxy-Authorization: ${params.auth}\r\n` : "";
|
||||
socket.write(
|
||||
`CONNECT localhost:${originPort} HTTP/1.1\r\nHost: localhost:${originPort}\r\n${authLine}\r\n`,
|
||||
);
|
||||
const response = await new Promise<string>((resolve, reject) => {
|
||||
let buffered = "";
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered += chunk.toString("latin1");
|
||||
if (buffered.includes("\r\n\r\n")) {
|
||||
socket.off("data", onData);
|
||||
resolve(buffered);
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
socket.once("close", () => resolve(buffered));
|
||||
});
|
||||
return { response, socket };
|
||||
}
|
||||
|
||||
async function requestThroughTunnel(params: {
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyChunks?: readonly string[];
|
||||
caPath?: string;
|
||||
proxyEnv?: Record<string, string>;
|
||||
}): Promise<{ body: string; status: number }> {
|
||||
const env = params.proxyEnv ?? proxyEnv;
|
||||
const configuredProxy = env.HTTPS_PROXY;
|
||||
if (!configuredProxy) {
|
||||
throw new Error("test proxy environment is missing HTTPS_PROXY");
|
||||
}
|
||||
const connected = await rawConnect({
|
||||
auth: basicProxyAuth(registeredPassword(env)),
|
||||
proxyOrigin: new URL(configuredProxy).origin,
|
||||
});
|
||||
expect(connected.response).toContain("200 Connection Established");
|
||||
const secureSocket = tls.connect({
|
||||
socket: connected.socket,
|
||||
servername: "localhost",
|
||||
ca: fs.readFileSync(params.caPath ?? proxy.caCertPath),
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
secureSocket.once("secureConnect", resolve);
|
||||
secureSocket.once("error", reject);
|
||||
});
|
||||
const bodyChunks = params.bodyChunks ?? [];
|
||||
const headers = {
|
||||
Host: `localhost:${originPort}`,
|
||||
Connection: "close",
|
||||
...(bodyChunks.length > 0 ? { "Transfer-Encoding": "chunked" } : {}),
|
||||
...params.headers,
|
||||
};
|
||||
secureSocket.write(`POST ${params.path ?? "/"} HTTP/1.1\r\n`);
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
secureSocket.write(`${name}: ${value}\r\n`);
|
||||
}
|
||||
secureSocket.write("\r\n");
|
||||
for (const chunk of bodyChunks) {
|
||||
secureSocket.write(`${Buffer.byteLength(chunk).toString(16)}\r\n${chunk}\r\n`);
|
||||
}
|
||||
if (bodyChunks.length > 0) {
|
||||
secureSocket.write("0\r\n\r\n");
|
||||
}
|
||||
const raw = await new Promise<string>((resolve, reject) => {
|
||||
let output = "";
|
||||
secureSocket.setEncoding("utf8");
|
||||
secureSocket.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
secureSocket.once("end", () => resolve(output));
|
||||
secureSocket.once("error", reject);
|
||||
});
|
||||
const [head = "", body = ""] = raw.split("\r\n\r\n", 2);
|
||||
const status = Number(/^HTTP\/1\.1 (\d{3})/u.exec(head)?.[1]);
|
||||
return { body, status };
|
||||
}
|
||||
|
||||
async function forwardedRequest(auth?: string, protocol = "https"): Promise<number> {
|
||||
const proxyUrl = new URL(proxy.proxyOrigin);
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{
|
||||
hostname: proxyUrl.hostname,
|
||||
port: proxyUrl.port,
|
||||
path: `${protocol}://localhost:${originPort}/forwarded-auth`,
|
||||
method: "GET",
|
||||
headers: auth ? { "Proxy-Authorization": auth } : undefined,
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
response.once("end", () => resolve(response.statusCode ?? 0));
|
||||
},
|
||||
);
|
||||
request.once("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function tamperSentinel(sentinel: string): string {
|
||||
const index = SECRET_SENTINEL_PREFIX.length;
|
||||
const replacement = sentinel[index] === "A" ? "B" : "A";
|
||||
return `${sentinel.slice(0, index)}${replacement}${sentinel.slice(index + 1)}`;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
auditEvents = [];
|
||||
originRequests = [];
|
||||
caDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-egress-proxy-test-"));
|
||||
tempDirs.push(caDir);
|
||||
proxy = await startSecretEgressProxyServer({
|
||||
caDir,
|
||||
onAudit: (event) => auditEvents.push(event),
|
||||
});
|
||||
proxies.push(proxy);
|
||||
const leaf = await generateLocalProxyLeaf({
|
||||
certDir: caDir,
|
||||
ca: { certPath: proxy.caCertPath, keyPath: path.join(caDir, "root-ca-key.pem") },
|
||||
hostname: "localhost",
|
||||
});
|
||||
originPort = await listen(
|
||||
createHttpsServer(leaf, (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
request.on("end", () => {
|
||||
originRequests.push({
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
headers: { ...request.headers },
|
||||
url: request.url ?? "",
|
||||
});
|
||||
response.writeHead(200, { Connection: "close", "Content-Length": 2 });
|
||||
response.end("ok");
|
||||
});
|
||||
}),
|
||||
);
|
||||
run = Object.freeze({ instanceId: "instance-1", runId: "run-1" });
|
||||
proxyEnv = proxy.registerRun(run);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
sockets.clear();
|
||||
for (const currentProxy of proxies.splice(0)) {
|
||||
await currentProxy.stop();
|
||||
}
|
||||
for (const server of servers.splice(0)) {
|
||||
await closeServer(server);
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("secret egress proxy", () => {
|
||||
it.each([
|
||||
{ label: "missing", auth: undefined, expectedReason: "missing-proxy-auth" },
|
||||
{
|
||||
label: "wrong",
|
||||
auth: basicProxyAuth("wrong-token"),
|
||||
expectedReason: "invalid-proxy-auth",
|
||||
},
|
||||
])("refuses $label authentication on CONNECT and forwarded requests", async (testCase) => {
|
||||
const connect = await rawConnect({ auth: testCase.auth });
|
||||
expect(connect.response).toContain("407 Proxy Authentication Required");
|
||||
connect.socket.destroy();
|
||||
|
||||
await expect(forwardedRequest(testCase.auth)).resolves.toBe(407);
|
||||
expect(originRequests).toEqual([]);
|
||||
expect(auditEvents).toEqual([
|
||||
expect.objectContaining({ kind: "refused", reason: testCase.expectedReason }),
|
||||
expect.objectContaining({ kind: "refused", reason: testCase.expectedReason }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("substitutes an authenticated header and strips proxy authorization upstream", async () => {
|
||||
const secret = "header-secret-value";
|
||||
const sentinel = mintSecretSentinel(secret, { label: "egress-header" });
|
||||
proxyEnv = registerSentinel({ sentinel, allowedHosts: ["LOCALHOST"] });
|
||||
expect(fs.statSync(caDir).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(path.join(caDir, "root-ca-key.pem")).mode & 0o777).toBe(0o600);
|
||||
|
||||
await expect(
|
||||
requestThroughTunnel({ headers: { Authorization: `Bearer ${sentinel}` } }),
|
||||
).resolves.toMatchObject({ body: "ok", status: 200 });
|
||||
|
||||
expect(originRequests).toHaveLength(1);
|
||||
expect(originRequests[0]?.headers.authorization).toBe(`Bearer ${secret}`);
|
||||
expect(originRequests[0]?.headers).not.toHaveProperty("proxy-authorization");
|
||||
expect(auditEvents).toContainEqual(
|
||||
expect.objectContaining({ kind: "forwarded", host: "localhost", substituted: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "an unbound host", allowedHosts: ["api.example.com"] },
|
||||
{ label: "no bound hosts", allowedHosts: [] },
|
||||
])(
|
||||
"refuses substitution for $label before the real value reaches the origin",
|
||||
async (testCase) => {
|
||||
const secret = `never-forward-${testCase.label}`;
|
||||
const sentinel = mintSecretSentinel(secret, { label: `egress-${testCase.label}` });
|
||||
proxyEnv = registerSentinel({
|
||||
sentinel,
|
||||
allowedHosts: testCase.allowedHosts,
|
||||
name: "SERVICE_API_KEY",
|
||||
});
|
||||
|
||||
const result = await requestThroughTunnel({
|
||||
headers: { Authorization: `Bearer ${sentinel}` },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: 502 });
|
||||
expect(result.body).toContain(
|
||||
"openclaw secrets store set SERVICE_API_KEY --allow-host localhost",
|
||||
);
|
||||
expect(originRequests).toEqual([]);
|
||||
expect(JSON.stringify(originRequests)).not.toContain(secret);
|
||||
expect(auditEvents.at(-1)).toMatchObject({
|
||||
kind: "refused",
|
||||
host: "localhost",
|
||||
reason: "destination-not-allowed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["url", "header", "body"] as const)(
|
||||
"refuses an unresolved sentinel in the %s",
|
||||
async (location) => {
|
||||
const unknown = tamperSentinel(
|
||||
mintSecretSentinel(`unknown-${location}`, { label: `egress-${location}` }),
|
||||
);
|
||||
const before = originRequests.length;
|
||||
const result = await requestThroughTunnel({
|
||||
path: location === "url" ? `/refuse?token=${unknown}` : "/refuse",
|
||||
headers: location === "header" ? { "X-Token": unknown } : undefined,
|
||||
bodyChunks: location === "body" ? [unknown] : undefined,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(502);
|
||||
expect(originRequests).toHaveLength(before);
|
||||
expect(auditEvents.at(-1)).toMatchObject({
|
||||
kind: "refused",
|
||||
reason: "unresolved-sentinel",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("substitutes a streamed body larger than the maximum carry window", async () => {
|
||||
const secret = "stream-boundary-secret";
|
||||
const sentinel = mintSecretSentinel(secret, { label: "egress-stream" });
|
||||
proxyEnv = registerSentinel({ sentinel, allowedHosts: ["localhost"] });
|
||||
const split = SECRET_SENTINEL_PREFIX.length + 3;
|
||||
const prefix = "x".repeat(SECRET_SENTINEL_MAX_LENGTH + 1024);
|
||||
const suffix = "y".repeat(2048);
|
||||
|
||||
await expect(
|
||||
requestThroughTunnel({
|
||||
bodyChunks: [prefix, sentinel.slice(0, split), sentinel.slice(split), suffix],
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 200 });
|
||||
|
||||
expect(originRequests.at(-1)?.body).toBe(`${prefix}${secret}${suffix}`);
|
||||
expect(originRequests.at(-1)?.body).not.toContain(sentinel);
|
||||
});
|
||||
|
||||
it("blind-tunnels bypassed hosts without substituting sentinels", async () => {
|
||||
const bypassEvents: SecretEgressProxyAuditEvent[] = [];
|
||||
const bypassProxy = await startSecretEgressProxyServer({
|
||||
caDir: fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-egress-bypass-test-")),
|
||||
bypassHosts: ["localhost"],
|
||||
onAudit: (event) => bypassEvents.push(event),
|
||||
});
|
||||
proxies.push(bypassProxy);
|
||||
tempDirs.push(path.dirname(bypassProxy.caCertPath));
|
||||
const bypassEnv = bypassProxy.registerRun(run);
|
||||
const sentinel = mintSecretSentinel("bypass-secret", { label: "egress-bypass" });
|
||||
|
||||
await expect(
|
||||
requestThroughTunnel({
|
||||
caPath: proxy.caCertPath,
|
||||
headers: { Authorization: `Bearer ${sentinel}` },
|
||||
proxyEnv: bypassEnv,
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 200 });
|
||||
|
||||
expect(originRequests.at(-1)?.headers.authorization).toBe(`Bearer ${sentinel}`);
|
||||
expect(bypassEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "forwarded",
|
||||
reason: "bypass",
|
||||
substituted: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("revokes Basic authorization with the exact owning run and keeps audits payload-free", async () => {
|
||||
const secret = "audit-secret-value";
|
||||
const sentinel = mintSecretSentinel(secret, { label: "egress-audit" });
|
||||
proxyEnv = registerSentinel({ sentinel, allowedHosts: ["localhost"] });
|
||||
await requestThroughTunnel({ headers: { "X-Secret": sentinel } });
|
||||
await expect(
|
||||
forwardedRequest(basicProxyAuth(registeredPassword(proxyEnv)), "http"),
|
||||
).resolves.toBe(502);
|
||||
expect(auditEvents).toContainEqual(
|
||||
expect.objectContaining({ kind: "refused", reason: "non-https-request" }),
|
||||
);
|
||||
|
||||
proxy.revokeRun(run);
|
||||
const refused = await rawConnect({
|
||||
auth: basicProxyAuth(registeredPassword(proxyEnv)),
|
||||
});
|
||||
expect(refused.response).toContain("407 Proxy Authentication Required");
|
||||
refused.socket.destroy();
|
||||
|
||||
const auditText = JSON.stringify(auditEvents);
|
||||
expect(auditText).not.toContain(secret);
|
||||
expect(auditText).not.toContain(sentinel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
createServer as createHttpServer,
|
||||
type IncomingHttpHeaders,
|
||||
type IncomingMessage,
|
||||
type RequestOptions,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import {
|
||||
Agent as HttpsAgent,
|
||||
createServer as createHttpsServer,
|
||||
request as httpsRequest,
|
||||
type Server as HttpsServer,
|
||||
} from "node:https";
|
||||
import net, { type Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { rootCertificates } from "node:tls";
|
||||
import { domainToASCII, URL } from "node:url";
|
||||
import { ensureSecretEgressProxyCa, generateLocalProxyLeaf } from "../../proxy-capture/ca.js";
|
||||
import {
|
||||
containsSecretSentinel,
|
||||
resolveSecretSentinel,
|
||||
SECRET_SENTINEL_PATTERN,
|
||||
} from "../sentinel.js";
|
||||
import {
|
||||
createSecretEgressBodyTransform,
|
||||
SecretEgressSubstitutionError,
|
||||
type SecretEgressRefusalReason,
|
||||
} from "./stream-substitution.js";
|
||||
|
||||
const PROXY_AUTH_USERNAME = "openclaw";
|
||||
const PROXY_AUTH_REALM = "OpenClaw secret egress";
|
||||
const REFUSAL_BODY = "Secret egress proxy refused the request.\n";
|
||||
const UPSTREAM_ERROR_BODY = "Secret egress proxy could not reach the upstream host.\n";
|
||||
|
||||
type SecretEgressProxyAuditEvent = {
|
||||
kind: "forwarded" | "refused";
|
||||
host: string;
|
||||
substituted: boolean;
|
||||
reason?: SecretEgressRefusalReason | "bypass";
|
||||
};
|
||||
|
||||
export type SecretEgressSentinelBinding = Readonly<{
|
||||
name: string;
|
||||
sentinel: string;
|
||||
allowedHosts: readonly string[];
|
||||
}>;
|
||||
|
||||
export type SecretEgressProxyHandle = {
|
||||
caCertPath: string;
|
||||
proxyOrigin: string;
|
||||
registerRun: (
|
||||
run: Readonly<{ instanceId: string; runId: string }>,
|
||||
bindings?: readonly SecretEgressSentinelBinding[],
|
||||
) => Record<string, string>;
|
||||
revokeRun: (run: Readonly<{ instanceId: string; runId: string }>) => void;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ConnectTarget = { hostname: string; port: number };
|
||||
type RegisteredRun = {
|
||||
digest: Buffer;
|
||||
key: string;
|
||||
sentinelBindings: Map<string, { allowedHosts: Set<string>; name: string }>;
|
||||
token: string;
|
||||
};
|
||||
|
||||
function normalizeHostname(raw: string): string {
|
||||
const trimmed = raw.trim().toLowerCase().replace(/\.+$/, "");
|
||||
const unbracketed =
|
||||
trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
if (net.isIP(unbracketed)) {
|
||||
return unbracketed;
|
||||
}
|
||||
const ascii = domainToASCII(unbracketed);
|
||||
if (
|
||||
!ascii ||
|
||||
ascii.length > 253 ||
|
||||
ascii
|
||||
.split(".")
|
||||
.some(
|
||||
(label) =>
|
||||
!label ||
|
||||
label.length > 63 ||
|
||||
label.startsWith("-") ||
|
||||
label.endsWith("-") ||
|
||||
!/^[a-z0-9-]+$/u.test(label),
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid proxy target hostname");
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
|
||||
function parseConnectTarget(rawTarget: string | undefined): ConnectTarget {
|
||||
const raw = rawTarget?.trim();
|
||||
if (!raw || /[\r\n]/u.test(raw)) {
|
||||
throw new Error("Invalid CONNECT target");
|
||||
}
|
||||
const target = new URL(`https://${raw}`);
|
||||
if (
|
||||
target.pathname !== "/" ||
|
||||
target.search ||
|
||||
target.hash ||
|
||||
target.username ||
|
||||
target.password
|
||||
) {
|
||||
throw new Error("Invalid CONNECT target");
|
||||
}
|
||||
const port = target.port ? Number(target.port) : 443;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error("Invalid CONNECT target port");
|
||||
}
|
||||
return { hostname: normalizeHostname(target.hostname), port };
|
||||
}
|
||||
|
||||
function runKey(run: Readonly<{ instanceId: string; runId: string }>): string {
|
||||
return `${run.runId}\0${run.instanceId}`;
|
||||
}
|
||||
|
||||
// Proxy tokens are 256-bit random bearer credentials, not user-chosen passwords, so a
|
||||
// slow KDF would add per-request latency on the proxy hot path without making brute force
|
||||
// any more infeasible. A process-keyed MAC is the right primitive: it normalizes attacker-
|
||||
// controlled input to a fixed length for constant-time compare, and a leaked digest cannot
|
||||
// be correlated back to a token without the in-memory key.
|
||||
const tokenMacKey = randomBytes(32);
|
||||
|
||||
function tokenDigest(token: string): Buffer {
|
||||
return createHmac("sha256", tokenMacKey).update(token).digest();
|
||||
}
|
||||
|
||||
function parseBasicProxyPassword(header: string | string[] | undefined): string | undefined {
|
||||
if (typeof header !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/iu.exec(header.trim());
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(match[1], "base64").toString("utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const colon = decoded.indexOf(":");
|
||||
if (colon === -1 || decoded.slice(0, colon) !== PROXY_AUTH_USERNAME) {
|
||||
return undefined;
|
||||
}
|
||||
return decoded.slice(colon + 1);
|
||||
}
|
||||
|
||||
function sendProxyAuthRequired(socket: Duplex): void {
|
||||
socket.end(
|
||||
`HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="${PROXY_AUTH_REALM}"\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(REFUSAL_BODY)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${REFUSAL_BODY}`,
|
||||
);
|
||||
}
|
||||
|
||||
function sendHttpRefusal(res: ServerResponse, status = 502, body = REFUSAL_BODY): void {
|
||||
if (res.destroyed || res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
res.writeHead(status, {
|
||||
Connection: "close",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function resolveRegisteredSentinel(params: {
|
||||
sentinel: string;
|
||||
host: string;
|
||||
registered: RegisteredRun;
|
||||
}): string | undefined {
|
||||
const binding = params.registered.sentinelBindings.get(params.sentinel);
|
||||
if (!binding) {
|
||||
return undefined;
|
||||
}
|
||||
if (!binding.allowedHosts.has(params.host)) {
|
||||
throw new SecretEgressSubstitutionError("destination-not-allowed", {
|
||||
host: params.host,
|
||||
secretName: binding.name,
|
||||
});
|
||||
}
|
||||
return resolveSecretSentinel(params.sentinel);
|
||||
}
|
||||
|
||||
function swapRequestText(params: {
|
||||
value: string;
|
||||
urlMode: boolean;
|
||||
host: string;
|
||||
registered: RegisteredRun;
|
||||
}): { value: string; substituted: boolean } {
|
||||
if (!containsSecretSentinel(params.value)) {
|
||||
return { value: params.value, substituted: false };
|
||||
}
|
||||
let substituted = false;
|
||||
const swapped = params.value.replace(
|
||||
new RegExp(SECRET_SENTINEL_PATTERN.source, "g"),
|
||||
(sentinel) => {
|
||||
const resolved = resolveRegisteredSentinel({
|
||||
sentinel,
|
||||
host: params.host,
|
||||
registered: params.registered,
|
||||
});
|
||||
if (resolved === undefined) {
|
||||
return sentinel;
|
||||
}
|
||||
substituted = true;
|
||||
return params.urlMode ? encodeURIComponent(resolved) : resolved;
|
||||
},
|
||||
);
|
||||
if (containsSecretSentinel(swapped)) {
|
||||
throw new SecretEgressSubstitutionError("unresolved-sentinel");
|
||||
}
|
||||
return { value: swapped, substituted };
|
||||
}
|
||||
|
||||
function swapRequestHeaders(params: {
|
||||
headers: IncomingHttpHeaders;
|
||||
host: string;
|
||||
registered: RegisteredRun;
|
||||
}): {
|
||||
headers: IncomingHttpHeaders;
|
||||
substituted: boolean;
|
||||
} {
|
||||
const output: IncomingHttpHeaders = {};
|
||||
let substituted = false;
|
||||
for (const [name, rawValue] of Object.entries(params.headers)) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName === "proxy-authorization" || lowerName === "proxy-connection") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
output[name] = rawValue.map((value) => {
|
||||
const swapped = swapRequestText({
|
||||
value,
|
||||
urlMode: false,
|
||||
host: params.host,
|
||||
registered: params.registered,
|
||||
});
|
||||
substituted ||= swapped.substituted;
|
||||
return swapped.value;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (rawValue !== undefined) {
|
||||
const swapped = swapRequestText({
|
||||
value: rawValue,
|
||||
urlMode: false,
|
||||
host: params.host,
|
||||
registered: params.registered,
|
||||
});
|
||||
substituted ||= swapped.substituted;
|
||||
output[name] = swapped.value;
|
||||
}
|
||||
}
|
||||
delete output["content-length"];
|
||||
delete output["transfer-encoding"];
|
||||
return { headers: output, substituted };
|
||||
}
|
||||
|
||||
function createUpstreamRequestOptions(params: {
|
||||
target: URL;
|
||||
request: IncomingMessage;
|
||||
headers: IncomingHttpHeaders;
|
||||
}): RequestOptions {
|
||||
return {
|
||||
hostname: params.target.hostname,
|
||||
port: params.target.port || (params.target.protocol === "https:" ? 443 : 80),
|
||||
path: `${params.target.pathname}${params.target.search}`,
|
||||
method: params.request.method,
|
||||
headers: params.headers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts one authenticated, loopback-only substitution proxy. */
|
||||
export async function startSecretEgressProxyServer(params: {
|
||||
caDir: string;
|
||||
bypassHosts?: readonly string[];
|
||||
onAudit: (event: SecretEgressProxyAuditEvent) => void;
|
||||
}): Promise<SecretEgressProxyHandle> {
|
||||
const ca = await ensureSecretEgressProxyCa(params.caDir);
|
||||
const caPem = fs.readFileSync(ca.certPath, "utf8");
|
||||
const trustBundlePath = path.join(params.caDir, "trust-bundle.pem");
|
||||
fs.writeFileSync(trustBundlePath, `${rootCertificates.join("\n")}\n${caPem}`, { mode: 0o644 });
|
||||
const upstreamTlsAgent = new HttpsAgent({
|
||||
ca: [...rootCertificates, caPem],
|
||||
});
|
||||
const bypassHosts = new Set((params.bypassHosts ?? []).map(normalizeHostname));
|
||||
const tokens = new Map<string, RegisteredRun>();
|
||||
const sockets = new Set<Socket>();
|
||||
const tlsServers = new Map<string, Promise<HttpsServer>>();
|
||||
|
||||
const audit = (event: SecretEgressProxyAuditEvent) => params.onAudit(event);
|
||||
const authorize = (
|
||||
headers: IncomingHttpHeaders,
|
||||
): RegisteredRun | Exclude<SecretEgressRefusalReason, "destination-not-allowed"> => {
|
||||
const rawHeader = headers["proxy-authorization"];
|
||||
if (rawHeader === undefined) {
|
||||
return "missing-proxy-auth";
|
||||
}
|
||||
const password = parseBasicProxyPassword(rawHeader);
|
||||
if (!password) {
|
||||
return "invalid-proxy-auth";
|
||||
}
|
||||
const candidate = tokenDigest(password);
|
||||
for (const registered of tokens.values()) {
|
||||
if (timingSafeEqual(candidate, registered.digest)) {
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
return "invalid-proxy-auth";
|
||||
};
|
||||
|
||||
const forwardRequest = (forward: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
target: URL;
|
||||
registered: RegisteredRun;
|
||||
}) => {
|
||||
const host = normalizeHostname(forward.target.hostname);
|
||||
if (forward.target.protocol !== "https:") {
|
||||
audit({
|
||||
kind: "refused",
|
||||
host,
|
||||
substituted: false,
|
||||
reason: "non-https-request",
|
||||
});
|
||||
sendHttpRefusal(forward.response);
|
||||
forward.request.resume();
|
||||
return;
|
||||
}
|
||||
let substituted = false;
|
||||
let target: URL;
|
||||
let headers: IncomingHttpHeaders;
|
||||
try {
|
||||
const swappedUrl = swapRequestText({
|
||||
value: forward.target.toString(),
|
||||
urlMode: true,
|
||||
host,
|
||||
registered: forward.registered,
|
||||
});
|
||||
target = new URL(swappedUrl.value);
|
||||
const swappedHeaders = swapRequestHeaders({
|
||||
headers: forward.request.headers,
|
||||
host,
|
||||
registered: forward.registered,
|
||||
});
|
||||
headers = swappedHeaders.headers;
|
||||
headers.host = target.host;
|
||||
substituted = swappedUrl.substituted || swappedHeaders.substituted;
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof SecretEgressSubstitutionError ? error.reason : "unresolved-sentinel";
|
||||
audit({ kind: "refused", host, substituted, reason });
|
||||
sendHttpRefusal(
|
||||
forward.response,
|
||||
502,
|
||||
error instanceof SecretEgressSubstitutionError ? `${error.message}\n` : REFUSAL_BODY,
|
||||
);
|
||||
forward.request.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyTransform = createSecretEgressBodyTransform({
|
||||
onSubstitution: () => {
|
||||
substituted = true;
|
||||
},
|
||||
resolveSentinel: (sentinel) =>
|
||||
resolveRegisteredSentinel({ sentinel, host, registered: forward.registered }),
|
||||
});
|
||||
let refused = false;
|
||||
let forwardedLogged = false;
|
||||
const requestOptions = createUpstreamRequestOptions({
|
||||
target,
|
||||
request: forward.request,
|
||||
headers,
|
||||
});
|
||||
const upstream = httpsRequest(
|
||||
{
|
||||
...requestOptions,
|
||||
agent: upstreamTlsAgent,
|
||||
},
|
||||
(upstreamResponse) => {
|
||||
if (refused) {
|
||||
upstreamResponse.destroy();
|
||||
return;
|
||||
}
|
||||
forward.response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
|
||||
upstreamResponse.pipe(forward.response);
|
||||
},
|
||||
);
|
||||
bodyTransform.once("finish", () => {
|
||||
if (!refused && !forwardedLogged) {
|
||||
forwardedLogged = true;
|
||||
audit({ kind: "forwarded", host, substituted });
|
||||
}
|
||||
});
|
||||
bodyTransform.once("error", (error) => {
|
||||
if (refused) {
|
||||
return;
|
||||
}
|
||||
refused = true;
|
||||
forward.request.unpipe(bodyTransform);
|
||||
forward.request.resume();
|
||||
upstream.destroy();
|
||||
const reason =
|
||||
error instanceof SecretEgressSubstitutionError ? error.reason : "unresolved-sentinel";
|
||||
audit({ kind: "refused", host, substituted, reason });
|
||||
sendHttpRefusal(
|
||||
forward.response,
|
||||
502,
|
||||
error instanceof SecretEgressSubstitutionError ? `${error.message}\n` : REFUSAL_BODY,
|
||||
);
|
||||
});
|
||||
upstream.once("error", () => {
|
||||
if (refused) {
|
||||
return;
|
||||
}
|
||||
refused = true;
|
||||
audit({ kind: "refused", host, substituted, reason: "upstream-error" });
|
||||
sendHttpRefusal(forward.response, 502, UPSTREAM_ERROR_BODY);
|
||||
});
|
||||
forward.request.pipe(bodyTransform).pipe(upstream);
|
||||
};
|
||||
|
||||
const tlsServerFor = (target: ConnectTarget, registered: RegisteredRun): Promise<HttpsServer> => {
|
||||
const key = `${registered.key}\0${target.hostname}:${target.port}`;
|
||||
let server = tlsServers.get(key);
|
||||
if (!server) {
|
||||
server = generateLocalProxyLeaf({
|
||||
certDir: params.caDir,
|
||||
ca,
|
||||
hostname: target.hostname,
|
||||
}).then((leaf) =>
|
||||
createHttpsServer(leaf, (request, response) => {
|
||||
const targetUrl = new URL(
|
||||
request.url ?? "/",
|
||||
`https://${target.hostname}${target.port === 443 ? "" : `:${target.port}`}`,
|
||||
);
|
||||
forwardRequest({ request, response, target: targetUrl, registered });
|
||||
}),
|
||||
);
|
||||
tlsServers.set(key, server);
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
const proxy = createHttpServer((request, response) => {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(request.url ?? "");
|
||||
} catch {
|
||||
audit({
|
||||
kind: "refused",
|
||||
host: request.headers.host ?? "unknown",
|
||||
substituted: false,
|
||||
reason: "upstream-error",
|
||||
});
|
||||
sendHttpRefusal(response, 400);
|
||||
return;
|
||||
}
|
||||
const host = normalizeHostname(target.hostname);
|
||||
const authorization = authorize(request.headers);
|
||||
if (typeof authorization === "string") {
|
||||
audit({ kind: "refused", host, substituted: false, reason: authorization });
|
||||
response.writeHead(407, {
|
||||
"Proxy-Authenticate": `Basic realm="${PROXY_AUTH_REALM}"`,
|
||||
Connection: "close",
|
||||
"Content-Length": Buffer.byteLength(REFUSAL_BODY),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
});
|
||||
response.end(REFUSAL_BODY);
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
forwardRequest({ request, response, target, registered: authorization });
|
||||
});
|
||||
|
||||
proxy.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
proxy.on("connect", (request, clientSocket, head) => {
|
||||
void (async () => {
|
||||
let target: ConnectTarget;
|
||||
try {
|
||||
target = parseConnectTarget(request.url);
|
||||
} catch {
|
||||
audit({ kind: "refused", host: "unknown", substituted: false, reason: "upstream-error" });
|
||||
clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const authorization = authorize(request.headers);
|
||||
if (typeof authorization === "string") {
|
||||
audit({
|
||||
kind: "refused",
|
||||
host: target.hostname,
|
||||
substituted: false,
|
||||
reason: authorization,
|
||||
});
|
||||
sendProxyAuthRequired(clientSocket);
|
||||
return;
|
||||
}
|
||||
if (bypassHosts.has(target.hostname)) {
|
||||
const upstream = net.connect(target.port, target.hostname, () => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length > 0) {
|
||||
upstream.write(head);
|
||||
}
|
||||
clientSocket.pipe(upstream).pipe(clientSocket);
|
||||
audit({
|
||||
kind: "forwarded",
|
||||
host: target.hostname,
|
||||
substituted: false,
|
||||
reason: "bypass",
|
||||
});
|
||||
});
|
||||
sockets.add(upstream);
|
||||
upstream.once("close", () => sockets.delete(upstream));
|
||||
upstream.once("error", () => clientSocket.destroy());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tlsServer = await tlsServerFor(target, authorization);
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length > 0) {
|
||||
clientSocket.unshift(head);
|
||||
}
|
||||
tlsServer.emit("connection", clientSocket);
|
||||
} catch {
|
||||
audit({
|
||||
kind: "refused",
|
||||
host: target.hostname,
|
||||
substituted: false,
|
||||
reason: "upstream-error",
|
||||
});
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxy.once("error", reject);
|
||||
proxy.listen(0, "127.0.0.1", () => {
|
||||
proxy.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = proxy.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Secret egress proxy failed to bind loopback");
|
||||
}
|
||||
const proxyOrigin = `http://127.0.0.1:${address.port}`;
|
||||
let stopped = false;
|
||||
return {
|
||||
caCertPath: ca.certPath,
|
||||
proxyOrigin,
|
||||
registerRun: (run, bindings = []) => {
|
||||
const key = runKey(run);
|
||||
let registered = tokens.get(key);
|
||||
if (!registered) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
registered = {
|
||||
digest: tokenDigest(token),
|
||||
key,
|
||||
sentinelBindings: new Map(),
|
||||
token,
|
||||
};
|
||||
tokens.set(key, registered);
|
||||
}
|
||||
registered.sentinelBindings = new Map(
|
||||
bindings.map((binding) => [
|
||||
binding.sentinel,
|
||||
{
|
||||
allowedHosts: new Set(binding.allowedHosts.map(normalizeHostname)),
|
||||
name: binding.name,
|
||||
},
|
||||
]),
|
||||
);
|
||||
// Basic is deliberately used because curl and Go net/http derive it from
|
||||
// proxy-URL credentials. Base64 is acceptable here: loopback is the only
|
||||
// listener, the token is run-scoped, and a process that can read it from
|
||||
// this env can already read the sentinels that authorize substitution.
|
||||
const proxyUrl = `http://${PROXY_AUTH_USERNAME}:${registered.token}@127.0.0.1:${address.port}`;
|
||||
return {
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
NODE_EXTRA_CA_CERTS: trustBundlePath,
|
||||
SSL_CERT_FILE: trustBundlePath,
|
||||
CURL_CA_BUNDLE: trustBundlePath,
|
||||
REQUESTS_CA_BUNDLE: trustBundlePath,
|
||||
};
|
||||
},
|
||||
revokeRun: (run) => {
|
||||
tokens.delete(runKey(run));
|
||||
},
|
||||
stop: async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
tokens.clear();
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
sockets.clear();
|
||||
await new Promise<void>((resolve) => {
|
||||
proxy.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
|
||||
import type { SecretEgressProxyHandle, SecretEgressSentinelBinding } from "./proxy-server.js";
|
||||
|
||||
type SecretEgressProxyRegistryState = { activeProxy?: SecretEgressProxyHandle };
|
||||
const SECRET_EGRESS_PROXY_REGISTRY_KEY = Symbol.for("openclaw.secretEgressProxy.registry");
|
||||
|
||||
function getSecretEgressProxyRegistry(): SecretEgressProxyRegistryState {
|
||||
return resolveGlobalSingleton<SecretEgressProxyRegistryState>(
|
||||
SECRET_EGRESS_PROXY_REGISTRY_KEY,
|
||||
() => ({}),
|
||||
);
|
||||
}
|
||||
|
||||
export function publishSecretEgressProxy(proxy: SecretEgressProxyHandle): void {
|
||||
const registry = getSecretEgressProxyRegistry();
|
||||
if (registry.activeProxy) {
|
||||
throw new Error("Secret egress proxy is already active in this process");
|
||||
}
|
||||
registry.activeProxy = proxy;
|
||||
}
|
||||
|
||||
export function clearSecretEgressProxy(proxy: SecretEgressProxyHandle): void {
|
||||
const registry = getSecretEgressProxyRegistry();
|
||||
if (registry.activeProxy === proxy) {
|
||||
registry.activeProxy = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSecretEgressProxyActive(): boolean {
|
||||
return getSecretEgressProxyRegistry().activeProxy !== undefined;
|
||||
}
|
||||
|
||||
/** Returns the trusted subprocess environment for one exact admitted agent run. */
|
||||
export function registerSecretEgressProxyRun(
|
||||
run: Readonly<{ instanceId: string; runId: string }>,
|
||||
bindings: readonly SecretEgressSentinelBinding[],
|
||||
): Record<string, string> {
|
||||
const proxy = getSecretEgressProxyRegistry().activeProxy;
|
||||
if (!proxy) {
|
||||
throw new Error("Secret egress proxy is not active in this Gateway process");
|
||||
}
|
||||
return proxy.registerRun(run, bindings);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { startSecretEgressProxyServer, type SecretEgressProxyHandle } from "./proxy-server.js";
|
||||
import { clearSecretEgressProxy, publishSecretEgressProxy } from "./registry.js";
|
||||
|
||||
const log = createSubsystemLogger("secrets/egress-proxy");
|
||||
const SECRET_EGRESS_PROXY_DIR_MODE = 0o700;
|
||||
|
||||
function removeProxyDirBestEffort(proxyDir: string): void {
|
||||
try {
|
||||
fs.rmSync(proxyDir, { recursive: true, force: true });
|
||||
fs.rmdirSync(path.dirname(proxyDir));
|
||||
} catch {
|
||||
// The Gateway-owned CA is already unusable after token and socket teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleProxyDirs(parentDir: string): void {
|
||||
for (const entry of fs.readdirSync(parentDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith("gateway-")) {
|
||||
fs.rmSync(path.join(parentDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the process-local proxy and registers it as the current Gateway owner. */
|
||||
export async function startGatewaySecretEgressProxy(params: {
|
||||
bypassHosts?: readonly string[];
|
||||
}): Promise<SecretEgressProxyHandle> {
|
||||
const parentDir = path.join(resolveStateDir(), "secret-egress-proxy");
|
||||
fs.mkdirSync(parentDir, { recursive: true, mode: SECRET_EGRESS_PROXY_DIR_MODE });
|
||||
fs.chmodSync(parentDir, SECRET_EGRESS_PROXY_DIR_MODE);
|
||||
removeStaleProxyDirs(parentDir);
|
||||
const proxyDir = fs.mkdtempSync(path.join(parentDir, "gateway-"));
|
||||
fs.chmodSync(proxyDir, SECRET_EGRESS_PROXY_DIR_MODE);
|
||||
let proxy: SecretEgressProxyHandle | undefined;
|
||||
try {
|
||||
proxy = await startSecretEgressProxyServer({
|
||||
caDir: proxyDir,
|
||||
...(params.bypassHosts ? { bypassHosts: params.bypassHosts } : {}),
|
||||
onAudit: (event) => log.info("secret egress request", event),
|
||||
});
|
||||
const ownedProxy = proxy;
|
||||
const cleanupOnProcessExit = () => removeProxyDirBestEffort(proxyDir);
|
||||
process.once("exit", cleanupOnProcessExit);
|
||||
const handle: SecretEgressProxyHandle = {
|
||||
...ownedProxy,
|
||||
stop: async () => {
|
||||
clearSecretEgressProxy(handle);
|
||||
process.off("exit", cleanupOnProcessExit);
|
||||
try {
|
||||
await ownedProxy.stop();
|
||||
} finally {
|
||||
removeProxyDirBestEffort(proxyDir);
|
||||
}
|
||||
},
|
||||
};
|
||||
publishSecretEgressProxy(handle);
|
||||
return handle;
|
||||
} catch (error) {
|
||||
await proxy?.stop().catch(() => undefined);
|
||||
removeProxyDirBestEffort(proxyDir);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Transform, type TransformCallback } from "node:stream";
|
||||
import {
|
||||
looksLikeSecretSentinel,
|
||||
SECRET_SENTINEL_MAX_LENGTH,
|
||||
SECRET_SENTINEL_PREFIX,
|
||||
SECRET_SENTINEL_SUFFIX,
|
||||
} from "../sentinel.js";
|
||||
|
||||
const SENTINEL_PREFIX_BYTES = Buffer.from(SECRET_SENTINEL_PREFIX);
|
||||
const SENTINEL_SUFFIX_BYTES = Buffer.from(SECRET_SENTINEL_SUFFIX);
|
||||
|
||||
export type SecretEgressRefusalReason =
|
||||
| "invalid-proxy-auth"
|
||||
| "missing-proxy-auth"
|
||||
| "non-https-request"
|
||||
| "non-https-port"
|
||||
| "destination-not-allowed"
|
||||
| "unresolved-sentinel"
|
||||
| "upstream-error";
|
||||
|
||||
export class SecretEgressSubstitutionError extends Error {
|
||||
constructor(
|
||||
readonly reason: SecretEgressRefusalReason,
|
||||
readonly details?: { host: string; secretName: string },
|
||||
) {
|
||||
super(
|
||||
details
|
||||
? `Secret "${details.secretName}" is not allowed for host "${details.host}". Run: openclaw secrets store set ${details.secretName} --allow-host ${details.host}`
|
||||
: "Secret egress proxy refused an unresolved secret sentinel",
|
||||
);
|
||||
this.name = "SecretEgressSubstitutionError";
|
||||
}
|
||||
}
|
||||
|
||||
function processPendingBuffer(params: {
|
||||
buffer: Buffer;
|
||||
flush: boolean;
|
||||
onSubstitution: () => void;
|
||||
resolveSentinel: (sentinel: string) => string | undefined;
|
||||
push: (chunk: Buffer) => void;
|
||||
}): Buffer {
|
||||
let pending = params.buffer;
|
||||
for (;;) {
|
||||
const prefixIndex = pending.indexOf(SENTINEL_PREFIX_BYTES);
|
||||
if (prefixIndex === -1) {
|
||||
const carryBytes = params.flush
|
||||
? 0
|
||||
: Math.min(pending.length, SENTINEL_PREFIX_BYTES.length - 1);
|
||||
const emitBytes = pending.length - carryBytes;
|
||||
if (emitBytes > 0) {
|
||||
params.push(pending.subarray(0, emitBytes));
|
||||
}
|
||||
return carryBytes > 0 ? pending.subarray(emitBytes) : Buffer.alloc(0);
|
||||
}
|
||||
if (prefixIndex > 0) {
|
||||
params.push(pending.subarray(0, prefixIndex));
|
||||
pending = pending.subarray(prefixIndex);
|
||||
}
|
||||
const suffixIndex = pending.indexOf(SENTINEL_SUFFIX_BYTES, SENTINEL_PREFIX_BYTES.length);
|
||||
if (suffixIndex === -1) {
|
||||
if (params.flush || pending.length > SECRET_SENTINEL_MAX_LENGTH) {
|
||||
throw new SecretEgressSubstitutionError("unresolved-sentinel");
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
const sentinelEnd = suffixIndex + SENTINEL_SUFFIX_BYTES.length;
|
||||
if (sentinelEnd > SECRET_SENTINEL_MAX_LENGTH) {
|
||||
throw new SecretEgressSubstitutionError("unresolved-sentinel");
|
||||
}
|
||||
const sentinel = pending.subarray(0, sentinelEnd).toString("ascii");
|
||||
const resolved = looksLikeSecretSentinel(sentinel)
|
||||
? params.resolveSentinel(sentinel)
|
||||
: undefined;
|
||||
if (resolved === undefined) {
|
||||
throw new SecretEgressSubstitutionError("unresolved-sentinel");
|
||||
}
|
||||
params.push(Buffer.from(resolved, "utf8"));
|
||||
params.onSubstitution();
|
||||
pending = pending.subarray(sentinelEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrites process-local sentinels across arbitrary request-body chunk boundaries. */
|
||||
export function createSecretEgressBodyTransform(params: {
|
||||
onSubstitution: () => void;
|
||||
resolveSentinel: (sentinel: string) => string | undefined;
|
||||
}): Transform {
|
||||
let pending: Buffer = Buffer.alloc(0);
|
||||
return new Transform({
|
||||
transform(chunk: Buffer | string, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
try {
|
||||
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
pending = processPendingBuffer({
|
||||
buffer: pending.length > 0 ? Buffer.concat([pending, input]) : input,
|
||||
flush: false,
|
||||
onSubstitution: params.onSubstitution,
|
||||
resolveSentinel: params.resolveSentinel,
|
||||
push: (output) => this.push(output),
|
||||
});
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error as Error);
|
||||
}
|
||||
},
|
||||
flush(callback: TransformCallback) {
|
||||
try {
|
||||
pending = processPendingBuffer({
|
||||
buffer: pending,
|
||||
flush: true,
|
||||
onSubstitution: params.onSubstitution,
|
||||
resolveSentinel: params.resolveSentinel,
|
||||
push: (output) => this.push(output),
|
||||
});
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error as Error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+17
-5
@@ -1,8 +1,9 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
|
||||
const SECRET_SENTINEL_PREFIX = "oc-sent-v2.";
|
||||
const SECRET_SENTINEL_SUFFIX = ".end";
|
||||
export const SECRET_SENTINEL_PREFIX = "oc-sent-v2.";
|
||||
export const SECRET_SENTINEL_SUFFIX = ".end";
|
||||
const SECRET_SENTINEL_SOURCE = "oc-sent-v2\\.[A-Za-z0-9_-]+\\.end";
|
||||
const SECRET_SENTINEL_CIPHER = "aes-256-gcm";
|
||||
const SECRET_SENTINEL_NONCE_BYTES = 12;
|
||||
@@ -10,12 +11,23 @@ const SECRET_SENTINEL_SCOPE_BYTES = 8;
|
||||
const SECRET_SENTINEL_TAG_BYTES = 16;
|
||||
const SECRET_SENTINEL_HEADER_BYTES =
|
||||
SECRET_SENTINEL_SCOPE_BYTES + SECRET_SENTINEL_NONCE_BYTES + SECRET_SENTINEL_TAG_BYTES;
|
||||
// Shared-store secrets are capped at 64 KiB. The egress proxy uses this bound
|
||||
// to keep only one maximum sentinel in memory while scanning streamed bodies.
|
||||
export const SECRET_SENTINEL_MAX_LENGTH =
|
||||
SECRET_SENTINEL_PREFIX.length +
|
||||
Math.ceil(((SECRET_SENTINEL_HEADER_BYTES + 64 * 1024) * 4) / 3) +
|
||||
SECRET_SENTINEL_SUFFIX.length;
|
||||
|
||||
export const SECRET_SENTINEL_PATTERN = new RegExp(SECRET_SENTINEL_SOURCE, "g");
|
||||
|
||||
// One process key keeps sentinels resolvable for in-flight requests without a
|
||||
// plaintext registry that retains every historical credential until exit.
|
||||
const secretSentinelKeys = randomBytes(64);
|
||||
type SecretSentinelKeyState = { keys: Buffer };
|
||||
const SECRET_SENTINEL_KEY_STATE = Symbol.for("openclaw.secretSentinel.keys");
|
||||
// Bundled runtime chunks can instantiate this module independently. A process-global
|
||||
// key keeps their sentinels interoperable without retaining a plaintext registry.
|
||||
const secretSentinelKeys = resolveGlobalSingleton<SecretSentinelKeyState>(
|
||||
SECRET_SENTINEL_KEY_STATE,
|
||||
() => ({ keys: randomBytes(64) }),
|
||||
).keys;
|
||||
const secretSentinelCipherKey = secretSentinelKeys.subarray(0, 32);
|
||||
const secretSentinelNonceKey = secretSentinelKeys.subarray(32);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
deleteSecretStoreEntry,
|
||||
listSecretStoreEntries,
|
||||
purgeExpiredSecretStoreEntries,
|
||||
readSecretStoreExecEnvironment,
|
||||
readSecretStoreValue,
|
||||
SECRET_STORE_VALUE_MAX_BYTES,
|
||||
writeSecretStoreEntry,
|
||||
@@ -51,12 +52,17 @@ describe("secret store", () => {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "stored-super-secret",
|
||||
kind: "secret",
|
||||
allowedHosts: ["API.EXAMPLE.COM", "bücher.example"],
|
||||
updatedBy: "test",
|
||||
database,
|
||||
});
|
||||
|
||||
expect(listSecretStoreEntries({ scope: team, database })).toEqual([
|
||||
expect.objectContaining({ name: "SERVICE_API_KEY", kind: "secret" }),
|
||||
expect.objectContaining({
|
||||
name: "SERVICE_API_KEY",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com", "xn--bcher-kva.example"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "SERVICE_URL",
|
||||
kind: "env",
|
||||
@@ -69,6 +75,15 @@ describe("secret store", () => {
|
||||
value: "stored-super-secret",
|
||||
});
|
||||
expect(isSecretValueRegisteredForRedaction("stored-super-secret")).toBe(true);
|
||||
expect(
|
||||
readSecretStoreExecEnvironment({ includeSecretSentinels: true, database })
|
||||
.secretEgressBindings,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "SERVICE_API_KEY",
|
||||
allowedHosts: ["api.example.com", "xn--bcher-kva.example"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("soft-deletes idempotently and purges after the 30-day retention", () => {
|
||||
@@ -138,6 +153,23 @@ describe("secret store", () => {
|
||||
).toThrow(expect.objectContaining({ code: "SECRET_STORE_VALUE_TOO_LARGE" }));
|
||||
});
|
||||
|
||||
it.each(["*.example.com", "https://api.example.com", "api.example.com:443", "bad host"])(
|
||||
"rejects invalid allowed host %s at write time",
|
||||
(allowedHost) => {
|
||||
expect(() =>
|
||||
writeSecretStoreEntry({
|
||||
scope: team,
|
||||
name: "HOST_BOUND_SECRET",
|
||||
value: "value",
|
||||
kind: "secret",
|
||||
allowedHosts: [allowedHost],
|
||||
updatedBy: null,
|
||||
database: createDatabaseOptions(),
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "SECRET_STORE_INVALID_ALLOWED_HOST" }));
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an empty secret value but keeps empty env values legal", () => {
|
||||
const database = createDatabaseOptions();
|
||||
// A silently-empty secret (a failed `op read |` pipe) is undiagnosable later:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import net from "node:net";
|
||||
import { domainToASCII } from "node:url";
|
||||
import { err, ok, type Result } from "@openclaw/normalization-core/result";
|
||||
import type { Selectable } from "kysely";
|
||||
import { ENV_SECRET_REF_ID_RE } from "../../config/types.secrets.js";
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../../state/openclaw-state-db.js";
|
||||
import { mintSecretSentinel } from "../sentinel.js";
|
||||
|
||||
type SecretStoreDatabase = Pick<OpenClawStateKyselyDatabase, "secret_store_entries">;
|
||||
type SecretStoreRow = Selectable<OpenClawStateKyselyDatabase["secret_store_entries"]>;
|
||||
@@ -30,9 +33,22 @@ export type SecretStoreEntryMetadata = {
|
||||
updatedAtMs: number;
|
||||
createdAtMs: number;
|
||||
updatedBy: string | null;
|
||||
allowedHosts?: string[];
|
||||
valuePreview?: string;
|
||||
};
|
||||
|
||||
export type SecretStoreEgressBinding = {
|
||||
name: string;
|
||||
sentinel: string;
|
||||
allowedHosts: string[];
|
||||
};
|
||||
|
||||
export type SecretStoreExecEnvironment = {
|
||||
env?: Record<string, string>;
|
||||
secretSentinels?: Record<string, string>;
|
||||
secretEgressBindings?: SecretStoreEgressBinding[];
|
||||
};
|
||||
|
||||
type SecretStoreReadError =
|
||||
| { code: "SECRET_STORE_NOT_FOUND"; message: string }
|
||||
| { code: "SECRET_STORE_INVALID_NAME"; message: string }
|
||||
@@ -40,6 +56,7 @@ type SecretStoreReadError =
|
||||
|
||||
type SecretStoreValidationCode =
|
||||
| "SECRET_STORE_INVALID_NAME"
|
||||
| "SECRET_STORE_INVALID_ALLOWED_HOST"
|
||||
| "SECRET_STORE_VALUE_TOO_LARGE"
|
||||
| "SECRET_STORE_VALUE_EMPTY";
|
||||
|
||||
@@ -54,6 +71,7 @@ export class SecretStoreValidationError extends Error {
|
||||
}
|
||||
|
||||
export const SECRET_STORE_VALUE_MAX_BYTES = 64 * 1024;
|
||||
export const SECRET_STORE_ALLOWED_HOSTS_MAX = 128;
|
||||
const SECRET_STORE_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
|
||||
function normalizeScope(_scope: SecretStoreScope): { scopeKind: "team"; scopeId: "" } {
|
||||
@@ -89,6 +107,73 @@ function assertSecretStoreValue(value: string, kind: SecretStoreKind): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecretAllowedHost(raw: string): string {
|
||||
const trimmed = raw.trim().toLowerCase().replace(/\.+$/u, "");
|
||||
if (trimmed.includes("*")) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
`Allowed host "${raw}" cannot contain a wildcard; use one exact hostname.`,
|
||||
);
|
||||
}
|
||||
const unbracketed =
|
||||
trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
if (net.isIP(unbracketed)) {
|
||||
return unbracketed;
|
||||
}
|
||||
if (!unbracketed || unbracketed.includes(":") || /[\s/?#@]/u.test(unbracketed)) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
`Allowed host "${raw}" must be a hostname without a scheme, path, wildcard, or port.`,
|
||||
);
|
||||
}
|
||||
const ascii = domainToASCII(unbracketed);
|
||||
if (
|
||||
!ascii ||
|
||||
ascii.length > 253 ||
|
||||
ascii
|
||||
.split(".")
|
||||
.some(
|
||||
(label) =>
|
||||
!label ||
|
||||
label.length > 63 ||
|
||||
label.startsWith("-") ||
|
||||
label.endsWith("-") ||
|
||||
!/^[a-z0-9-]+$/u.test(label),
|
||||
)
|
||||
) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
`Allowed host "${raw}" is not a valid hostname.`,
|
||||
);
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
|
||||
export function normalizeSecretAllowedHosts(hosts: readonly string[]): string[] {
|
||||
if (hosts.length > SECRET_STORE_ALLOWED_HOSTS_MAX) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
`A secret can allow at most ${SECRET_STORE_ALLOWED_HOSTS_MAX} hosts.`,
|
||||
);
|
||||
}
|
||||
return [...new Set(hosts.map(normalizeSecretAllowedHost))].toSorted();
|
||||
}
|
||||
|
||||
function parseSecretAllowedHosts(raw: string | null | undefined): string[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) && parsed.every((host) => typeof host === "string")
|
||||
? normalizeSecretAllowedHosts(parsed)
|
||||
: [];
|
||||
} catch {
|
||||
// Corrupt policy is never interpreted permissively: an empty list fails closed.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingSecretStoreTableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -109,6 +194,7 @@ function toMetadata(row: SecretStoreRow): SecretStoreEntryMetadata {
|
||||
updatedAtMs: normalizeSqliteNumber(row.updated_at_ms) ?? 0,
|
||||
createdAtMs: normalizeSqliteNumber(row.created_at_ms) ?? 0,
|
||||
updatedBy: row.updated_by,
|
||||
...(row.kind === "secret" ? { allowedHosts: parseSecretAllowedHosts(row.allowed_hosts) } : {}),
|
||||
...(row.kind === "env" ? { valuePreview: row.value } : {}),
|
||||
};
|
||||
}
|
||||
@@ -143,6 +229,63 @@ export function listSecretStoreEntries(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures one coherent team-store snapshot for an agent run's exec environment. */
|
||||
export function readSecretStoreExecEnvironment(params: {
|
||||
includeSecretSentinels: boolean;
|
||||
database?: OpenClawStateDatabaseOptions;
|
||||
}): SecretStoreExecEnvironment {
|
||||
try {
|
||||
return (
|
||||
withExistingOpenClawStateDatabaseReadOnly(({ db: sqlite }) => {
|
||||
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
|
||||
const rows = executeSqliteQuerySync(
|
||||
sqlite,
|
||||
db
|
||||
.selectFrom("secret_store_entries")
|
||||
.selectAll()
|
||||
.where("scope_kind", "=", "team")
|
||||
.where("scope_id", "=", "")
|
||||
.where("deleted_at_ms", "is", null)
|
||||
.orderBy("name", "asc"),
|
||||
).rows;
|
||||
const env: Record<string, string> = {};
|
||||
const secretSentinels: Record<string, string> = {};
|
||||
const secretEgressBindings: SecretStoreEgressBinding[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "env") {
|
||||
env[row.name] = row.value;
|
||||
continue;
|
||||
}
|
||||
registerSecretValueForRedaction(row.value);
|
||||
if (params.includeSecretSentinels) {
|
||||
// Named placeholders disclose the credential name. The existing sentinel
|
||||
// is authenticated ciphertext, so an escaped value only fails vendor auth.
|
||||
const sentinel = mintSecretSentinel(row.value, {
|
||||
label: `exec-store:${row.name}`,
|
||||
});
|
||||
secretSentinels[row.name] = sentinel;
|
||||
secretEgressBindings.push({
|
||||
name: row.name,
|
||||
sentinel,
|
||||
allowedHosts: parseSecretAllowedHosts(row.allowed_hosts),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||
...(Object.keys(secretSentinels).length > 0 ? { secretSentinels } : {}),
|
||||
...(secretEgressBindings.length > 0 ? { secretEgressBindings } : {}),
|
||||
};
|
||||
}, params.database ?? {}) ?? {}
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissingSecretStoreTableError(error)) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSecretStoreValue(params: {
|
||||
scope: SecretStoreScope;
|
||||
name: string;
|
||||
@@ -197,11 +340,23 @@ export function writeSecretStoreEntry(params: {
|
||||
name: string;
|
||||
value: string;
|
||||
kind: SecretStoreKind;
|
||||
allowedHosts?: readonly string[];
|
||||
updatedBy: string | null;
|
||||
database?: OpenClawStateDatabaseOptions;
|
||||
}): void {
|
||||
assertSecretStoreName(params.name);
|
||||
assertSecretStoreValue(params.value, params.kind);
|
||||
if (params.kind === "env" && params.allowedHosts !== undefined) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
"Allowed hosts apply only to secret entries.",
|
||||
);
|
||||
}
|
||||
const allowedHosts =
|
||||
params.kind === "secret" && params.allowedHosts !== undefined
|
||||
? normalizeSecretAllowedHosts(params.allowedHosts)
|
||||
: undefined;
|
||||
const allowedHostsJson = allowedHosts?.length ? JSON.stringify(allowedHosts) : null;
|
||||
const { scopeKind, scopeId } = normalizeScope(params.scope);
|
||||
const now = Date.now();
|
||||
runOpenClawStateWriteTransaction(
|
||||
@@ -222,6 +377,7 @@ export function writeSecretStoreEntry(params: {
|
||||
updated_at_ms: now,
|
||||
updated_by: params.updatedBy,
|
||||
deleted_at_ms: null,
|
||||
allowed_hosts: allowedHostsJson,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["scope_kind", "scope_id", "name"]).doUpdateSet({
|
||||
@@ -230,6 +386,11 @@ export function writeSecretStoreEntry(params: {
|
||||
updated_at_ms: now,
|
||||
updated_by: params.updatedBy,
|
||||
deleted_at_ms: null,
|
||||
...(params.kind === "env"
|
||||
? { allowed_hosts: null }
|
||||
: allowedHosts !== undefined
|
||||
? { allowed_hosts: allowedHostsJson }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -239,6 +400,48 @@ export function writeSecretStoreEntry(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSecretStoreAllowedHosts(params: {
|
||||
scope: SecretStoreScope;
|
||||
name: string;
|
||||
allowedHosts: readonly string[];
|
||||
updatedBy: string | null;
|
||||
database?: OpenClawStateDatabaseOptions;
|
||||
}): void {
|
||||
assertSecretStoreName(params.name);
|
||||
const allowedHosts = normalizeSecretAllowedHosts(params.allowedHosts);
|
||||
const { scopeKind, scopeId } = normalizeScope(params.scope);
|
||||
const now = Date.now();
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db: sqlite }) => {
|
||||
ensureSecretStoreSchema(sqlite);
|
||||
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
|
||||
const updated = executeSqliteQuerySync(
|
||||
sqlite,
|
||||
db
|
||||
.updateTable("secret_store_entries")
|
||||
.set({
|
||||
allowed_hosts: allowedHosts.length ? JSON.stringify(allowedHosts) : null,
|
||||
updated_at_ms: now,
|
||||
updated_by: params.updatedBy,
|
||||
})
|
||||
.where("scope_kind", "=", scopeKind)
|
||||
.where("scope_id", "=", scopeId)
|
||||
.where("name", "=", params.name)
|
||||
.where("kind", "=", "secret")
|
||||
.where("deleted_at_ms", "is", null),
|
||||
);
|
||||
if (Number(updated.numAffectedRows ?? 0n) !== 1) {
|
||||
throw new SecretStoreValidationError(
|
||||
"SECRET_STORE_INVALID_ALLOWED_HOST",
|
||||
`Secret store entry "${params.name}" is missing or is not a secret entry.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
params.database,
|
||||
{ operationLabel: "secrets.store.allowed-hosts" },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSecretStoreEntry(params: {
|
||||
scope: SecretStoreScope;
|
||||
name: string;
|
||||
|
||||
@@ -172,6 +172,7 @@ describe("OpenClaw database maintenance schema validation", () => {
|
||||
"worker_session_placements.terminal_at_ms INTEGER",
|
||||
"worktrees.run_end_cleanup_json TEXT",
|
||||
"installed_plugin_index.workspace_dir TEXT",
|
||||
"secret_store_entries.allowed_hosts TEXT",
|
||||
]);
|
||||
|
||||
const database = createGlobalDatabase();
|
||||
|
||||
@@ -23,4 +23,5 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [
|
||||
{ columnName: "terminal_at_ms", dataType: "INTEGER", tableName: "worker_session_placements" },
|
||||
{ columnName: "run_end_cleanup_json", dataType: "TEXT", tableName: "worktrees" },
|
||||
{ columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" },
|
||||
{ columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" },
|
||||
] as const satisfies readonly LazyAdditiveStateColumnDefinition[];
|
||||
|
||||
@@ -30,3 +30,37 @@ it("keeps secret-store first use from installing later additive schema", () => {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lazily adds allowed_hosts to a v6 secret store without changing user_version", () => {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
try {
|
||||
database.exec(`
|
||||
PRAGMA user_version = 6;
|
||||
CREATE TABLE secret_store_entries (
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
updated_by TEXT,
|
||||
deleted_at_ms INTEGER,
|
||||
PRIMARY KEY (scope_kind, scope_id, name)
|
||||
) STRICT;
|
||||
`);
|
||||
|
||||
ensureSecretStoreSchema(database);
|
||||
|
||||
expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 6 });
|
||||
expect(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT name, type, "notnull", dflt_value FROM pragma_table_info(?) WHERE name = ?',
|
||||
)
|
||||
.get("secret_store_entries", "allowed_hosts"),
|
||||
).toEqual({ name: "allowed_hosts", type: "TEXT", notnull: 0, dflt_value: null });
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ function secretStoreSchemaSql(): string {
|
||||
/** Lazily install the additive secret store table and index on first write. */
|
||||
export function ensureSecretStoreSchema(database: DatabaseSync): void {
|
||||
database.exec(secretStoreSchemaSql()); // sqlite-allow-raw -- Canonical additive DDL only.
|
||||
ensureColumn(database, "secret_store_entries", "allowed_hosts TEXT");
|
||||
}
|
||||
|
||||
/** Lazily install durable MCP OAuth callback correlation on first feature use. */
|
||||
|
||||
+1
@@ -1119,6 +1119,7 @@ export interface SchemaMeta {
|
||||
}
|
||||
|
||||
export interface SecretStoreEntries {
|
||||
allowed_hosts: string | null;
|
||||
created_at_ms: number;
|
||||
deleted_at_ms: number | null;
|
||||
kind: string;
|
||||
|
||||
@@ -29,6 +29,7 @@ const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [
|
||||
"worker_session_placements.terminal_at_ms",
|
||||
"worktrees.run_end_cleanup_json",
|
||||
"installed_plugin_index.workspace_dir",
|
||||
"secret_store_entries.allowed_hosts",
|
||||
] as const;
|
||||
|
||||
const CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET = new Set<string>(CLAW_LAZY_ADDITIVE_STATE_COLUMNS);
|
||||
|
||||
@@ -2443,6 +2443,7 @@ CREATE TABLE IF NOT EXISTS secret_store_entries (
|
||||
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
|
||||
updated_by TEXT,
|
||||
deleted_at_ms INTEGER,
|
||||
allowed_hosts TEXT,
|
||||
CHECK ((scope_kind = 'team' AND scope_id = '') OR (scope_kind = 'identity' AND length(scope_id) > 0)),
|
||||
PRIMARY KEY (scope_kind, scope_id, name)
|
||||
) STRICT;
|
||||
|
||||
@@ -5658,6 +5658,11 @@ export const en: TranslationMap = {
|
||||
secretsStore: {
|
||||
name: "Name",
|
||||
value: "Value",
|
||||
allowedHosts: "Allowed hosts",
|
||||
allowedHostsPlaceholder: "api.example.com",
|
||||
allowedHostsHint:
|
||||
"Exact hostnames only, one per line or comma-separated. No wildcards or ports.",
|
||||
noAllowedHosts: "None",
|
||||
updated: "Last updated",
|
||||
by: "{time} by {name}",
|
||||
actions: "Actions",
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("secrets store state", () => {
|
||||
name: "SERVICE_URL",
|
||||
value: "https://service.test",
|
||||
kind: "env",
|
||||
allowedHosts: "",
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
@@ -83,4 +84,26 @@ describe("secrets store state", () => {
|
||||
"secrets.store.list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends parsed allowed hosts through the store RPC", async () => {
|
||||
const { client, request } = clientWithResponses([
|
||||
{ ok: true, reloaded: false },
|
||||
{ entries: [] },
|
||||
]);
|
||||
const state = createInitialSecretsStoreState({ client, connected: true });
|
||||
|
||||
await setSecretsStoreEntry(state, {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "secret",
|
||||
kind: "secret",
|
||||
allowedHosts: "api.example.com, uploads.example.com\napi2.example.com",
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "secrets.store.set", {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "secret",
|
||||
kind: "secret",
|
||||
allowedHosts: ["api.example.com", "uploads.example.com", "api2.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,10 @@ export type SecretsStoreDraft = {
|
||||
name: string;
|
||||
value: string;
|
||||
kind: "secret" | "env";
|
||||
allowedHosts: string;
|
||||
};
|
||||
|
||||
type SecretsStoreBulkEntry = SecretsStoreDraft;
|
||||
type SecretsStoreBulkEntry = Omit<SecretsStoreDraft, "allowedHosts">;
|
||||
|
||||
export type SecretsStoreState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -113,7 +114,19 @@ export function setSecretsStoreEntry(
|
||||
draft: SecretsStoreDraft,
|
||||
): Promise<SecretsStoreMutationResult | null> {
|
||||
return mutateAndReload(state, (client) =>
|
||||
client.request<SecretsStoreMutationResult>("secrets.store.set", draft),
|
||||
client.request<SecretsStoreMutationResult>("secrets.store.set", {
|
||||
name: draft.name,
|
||||
value: draft.value,
|
||||
kind: draft.kind,
|
||||
...(draft.kind === "secret"
|
||||
? {
|
||||
allowedHosts: draft.allowedHosts
|
||||
.split(/[\s,]+/u)
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ class SecretsPage extends OpenClawLightDomElement {
|
||||
|
||||
@state() private store = createInitialSecretsStoreState();
|
||||
@state() private dialogMode: SecretsDialogMode = null;
|
||||
@state() private draft: SecretsStoreDraft = { name: "", value: "", kind: "env" };
|
||||
@state() private draft: SecretsStoreDraft = {
|
||||
name: "",
|
||||
value: "",
|
||||
kind: "env",
|
||||
allowedHosts: "",
|
||||
};
|
||||
@state() private secretKindOverridden = false;
|
||||
@state() private bulkOpen = false;
|
||||
@state() private bulkRaw = "";
|
||||
@@ -113,7 +118,7 @@ class SecretsPage extends OpenClawLightDomElement {
|
||||
this.notice = null;
|
||||
this.formError = null;
|
||||
this.secretKindOverridden = false;
|
||||
this.draft = { name: "", value: "", kind: "env" };
|
||||
this.draft = { name: "", value: "", kind: "env", allowedHosts: "" };
|
||||
this.dialogMode = "add";
|
||||
}
|
||||
|
||||
@@ -128,6 +133,7 @@ class SecretsPage extends OpenClawLightDomElement {
|
||||
name: entry.name,
|
||||
value: entry.kind === "env" ? entry.value : "",
|
||||
kind: entry.kind,
|
||||
allowedHosts: entry.kind === "secret" ? (entry.allowedHosts ?? []).join("\n") : "",
|
||||
};
|
||||
this.dialogMode = "edit";
|
||||
}
|
||||
@@ -304,6 +310,7 @@ class SecretsPage extends OpenClawLightDomElement {
|
||||
onCloseDialog: () => this.closeDialog(),
|
||||
onDraftNameChange: (name) => this.changeDraftName(name),
|
||||
onDraftValueChange: (value) => this.patchDraft({ value }),
|
||||
onDraftAllowedHostsChange: (allowedHosts) => this.patchDraft({ allowedHosts }),
|
||||
onDraftSecretChange: (secret) => {
|
||||
this.secretKindOverridden = true;
|
||||
this.patchDraft({ kind: secret ? "secret" : "env" });
|
||||
|
||||
@@ -35,6 +35,7 @@ const secretEntry: SecretStoreEntry = {
|
||||
createdAtMs: 1_786_352_400_000,
|
||||
updatedAtMs: 1_786_352_400_000,
|
||||
updatedBy: "E2E Operator",
|
||||
allowedHosts: ["api.example.com"],
|
||||
};
|
||||
|
||||
const bulkEnvEntry: SecretStoreEntry = {
|
||||
@@ -46,6 +47,7 @@ const bulkEnvEntry: SecretStoreEntry = {
|
||||
const bulkSecretEntry: SecretStoreEntry = {
|
||||
...secretEntry,
|
||||
name: "BULK_PRIVATE_KEY",
|
||||
allowedHosts: [],
|
||||
};
|
||||
|
||||
async function capture(page: Page, fileName: string) {
|
||||
@@ -84,12 +86,18 @@ async function tableBodyContrast(page: Page): Promise<number> {
|
||||
|
||||
suite.define(() => {
|
||||
it("adds env and secret values, bulk imports, and deletes without revealing secrets", async () => {
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
await suite.withPage(
|
||||
{
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
...(captureUiProofEnabled
|
||||
? { recordVideo: { dir: proofDir, size: { height: 900, width: 1440 } } }
|
||||
: {}),
|
||||
},
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
@@ -133,9 +141,14 @@ suite.define(() => {
|
||||
await secretDialog.getByLabel("Name", { exact: true }).fill("SERVICE_API_KEY");
|
||||
expect(await secretDialog.locator('input[type="checkbox"]').isChecked()).toBe(true);
|
||||
await secretDialog.getByLabel("Value", { exact: true }).fill("super-secret-material");
|
||||
await secretDialog.locator('textarea[name="allowed-hosts"]').fill("api.example.com");
|
||||
await capture(page, "02-secret-allowed-hosts.png");
|
||||
await secretDialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("status").getByText("Saved SERVICE_API_KEY.").waitFor();
|
||||
expect(await page.content()).not.toContain("super-secret-material");
|
||||
expect(await page.getByRole("row", { name: /SERVICE_API_KEY/u }).textContent()).toContain(
|
||||
"api.example.com",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Bulk Add", exact: true }).click();
|
||||
const bulkDialog = page.locator('openclaw-modal-dialog[label="Bulk Add"]');
|
||||
@@ -156,6 +169,10 @@ suite.define(() => {
|
||||
expect(await page.getByRole("row", { name: /BULK_URL/u }).count()).toBe(0);
|
||||
|
||||
expect(await gateway.getRequests("secrets.store.set")).toHaveLength(4);
|
||||
expect((await gateway.getRequests("secrets.store.set"))[1]?.params).toMatchObject({
|
||||
name: "SERVICE_API_KEY",
|
||||
allowedHosts: ["api.example.com"],
|
||||
});
|
||||
expect(await gateway.getRequests("secrets.store.delete")).toHaveLength(1);
|
||||
expect(await page.content()).not.toContain("super-secret-material");
|
||||
expect(await tableBodyContrast(page)).toBeGreaterThanOrEqual(9.5);
|
||||
|
||||
@@ -32,7 +32,7 @@ function mount(
|
||||
canSet: true,
|
||||
canDelete: true,
|
||||
dialogMode: null,
|
||||
draft: { name: "", value: "", kind: "env" },
|
||||
draft: { name: "", value: "", kind: "env", allowedHosts: "" },
|
||||
formError: null,
|
||||
bulkOpen: false,
|
||||
bulkRaw: "",
|
||||
@@ -46,6 +46,7 @@ function mount(
|
||||
onCloseDialog: noop,
|
||||
onDraftNameChange: noop,
|
||||
onDraftValueChange: noop,
|
||||
onDraftAllowedHostsChange: noop,
|
||||
onDraftSecretChange: noop,
|
||||
onSubmitDraft: noop,
|
||||
onOpenBulk: noop,
|
||||
@@ -70,6 +71,7 @@ describe("secrets store view", () => {
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 2,
|
||||
updatedBy: "Operator",
|
||||
allowedHosts: ["api.example.com"],
|
||||
} as unknown as SecretStoreEntry;
|
||||
const env: SecretStoreEntry = {
|
||||
name: "SERVICE_URL",
|
||||
@@ -86,6 +88,23 @@ describe("secrets store view", () => {
|
||||
expect(container.innerHTML).not.toContain("must-never-render");
|
||||
expect(container.textContent).toContain("••••••••");
|
||||
expect(container.textContent).toContain("https://service.test");
|
||||
expect(container.textContent).toContain("api.example.com");
|
||||
});
|
||||
|
||||
it("shows the allowed-host field for secret add and edit dialogs", () => {
|
||||
const container = mount([], {
|
||||
dialogMode: "edit",
|
||||
draft: {
|
||||
name: "SERVICE_API_KEY",
|
||||
value: "replacement",
|
||||
kind: "secret",
|
||||
allowedHosts: "api.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const field = container.querySelector<HTMLTextAreaElement>('textarea[name="allowed-hosts"]');
|
||||
expect(field?.value).toBe("api.example.com");
|
||||
expect(container.textContent).toContain("Exact hostnames only");
|
||||
});
|
||||
|
||||
it("hides mutation controls when the gateway does not advertise them", () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ type SecretsStoreViewProps = {
|
||||
onCloseDialog: () => void;
|
||||
onDraftNameChange: (name: string) => void;
|
||||
onDraftValueChange: (value: string) => void;
|
||||
onDraftAllowedHostsChange: (allowedHosts: string) => void;
|
||||
onDraftSecretChange: (secret: boolean) => void;
|
||||
onSubmitDraft: () => void;
|
||||
onOpenBulk: () => void;
|
||||
@@ -120,6 +121,7 @@ function renderTable(props: SecretsStoreViewProps): TemplateResult {
|
||||
<tr>
|
||||
<th scope="col">${t("secretsStore.name")}</th>
|
||||
<th scope="col">${t("secretsStore.value")}</th>
|
||||
<th scope="col">${t("secretsStore.allowedHosts")}</th>
|
||||
<th scope="col">${t("secretsStore.updated")}</th>
|
||||
<th scope="col" class="secrets-store__actions-heading">
|
||||
<span class="settings-control__sr-label">${t("secretsStore.actions")}</span>
|
||||
@@ -142,6 +144,13 @@ function renderTable(props: SecretsStoreViewProps): TemplateResult {
|
||||
>${entry.kind === "env" ? entry.value : SECRET_MASK}</span
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<span class="secrets-store__hosts">
|
||||
${entry.kind === "secret" && (entry.allowedHosts?.length ?? 0) > 0
|
||||
? entry.allowedHosts?.join(", ")
|
||||
: t("secretsStore.noAllowedHosts")}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<time
|
||||
class="secrets-store__updated"
|
||||
@@ -226,6 +235,27 @@ function renderEntryDialog(props: SecretsStoreViewProps): TemplateResult | typeo
|
||||
<small>${t("secretsStore.hint")}</small>
|
||||
</span>
|
||||
</label>
|
||||
${props.draft.kind === "secret"
|
||||
? html`
|
||||
<label class="secrets-store-field">
|
||||
<span>${t("secretsStore.allowedHosts")}</span>
|
||||
<textarea
|
||||
class="settings-input secrets-store-dialog__hosts mono"
|
||||
name="allowed-hosts"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder=${t("secretsStore.allowedHostsPlaceholder")}
|
||||
?disabled=${props.busy}
|
||||
.value=${props.draft.allowedHosts}
|
||||
@input=${(event: Event) =>
|
||||
props.onDraftAllowedHostsChange(
|
||||
(event.currentTarget as HTMLTextAreaElement).value,
|
||||
)}
|
||||
></textarea>
|
||||
<small>${t("secretsStore.allowedHostsHint")}</small>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
${props.formError
|
||||
? html`<div class="callout danger" role="alert">${props.formError}</div>`
|
||||
: nothing}
|
||||
|
||||
@@ -34,15 +34,19 @@
|
||||
}
|
||||
|
||||
.secrets-store__table th:nth-child(1) {
|
||||
width: 27%;
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.secrets-store__table th:nth-child(2) {
|
||||
width: 38%;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.secrets-store__table th:nth-child(3) {
|
||||
width: 28%;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.secrets-store__table th:nth-child(4) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.secrets-store__table tbody tr:last-child td {
|
||||
@@ -79,6 +83,16 @@
|
||||
font-size: var(--control-ui-text-sm);
|
||||
}
|
||||
|
||||
.secrets-store__hosts {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--muted-strong);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secrets-store__actions-heading,
|
||||
.secrets-store__actions-cell {
|
||||
width: 44px;
|
||||
@@ -161,6 +175,11 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.secrets-store-dialog__hosts {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.secrets-store-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user