fix(dashboard): SecretRef fields corrupt on config save; model picker shows Not set#57527
fix(dashboard): SecretRef fields corrupt on config save; model picker shows Not set#57527bgrubin wants to merge 6 commits into
Conversation
…API access Add bearerToken and region to the amazon-bedrock plugin config schema. When bearerToken is configured (as a string or secret ref), inject it into process.env so the AWS SDK picks it up for both model discovery (ListFoundationModels) and inference requests. This allows configuring Bedrock credentials via openclaw.json instead of requiring manual LaunchAgent plist edits or shell env setup.
Adds a new bundled hook that replaces workspace bootstrap file slots (SOUL.md, IDENTITY.md, etc.) with content read from explicitly configured external source paths. Motivation: the workspace loader enforces a strict realpath boundary, rejecting symlinks whose targets canonically escape the workspace root. This silently marks files like SOUL.md/IDENTITY.md as [MISSING] when they are symlinked to Dropbox-backed shared locations. The new hook runs after the workspace loader via agent:bootstrap, and replaces entries in-place using operator-configured paths. Because the source paths are explicit in config rather than inferred, this is a controlled escape hatch that does not weaken the general boundary model. Changes: - src/hooks/bundled/bootstrap-alternate-files/handler.ts - src/hooks/bundled/bootstrap-alternate-files/HOOK.md - src/hooks/bundled/bootstrap-alternate-files/handler.test.ts (10 tests) - src/agents/workspace.ts: export VALID_BOOTSTRAP_NAMES (was private)
…r shows Not set
Three bugs found and fixed while diagnosing a config save failure where
any config.set from the dashboard was rejected with:
channels.discord.token.id: Env secret reference id must match pattern
Root cause (restore): The Discord token ui-hint lacked sensitive:true, so
restoreRedactedValues used the pattern-guessing path which checked
isSensitivePath('channels.discord.token.id') - false, since the path ends
in 'id' not 'token'. The OPENCLAW_REDACTED sentinel in the SecretRef's
id field was never restored to its original value before Zod validation.
Fix 1 (extensions/discord/src/config-ui-hints.ts):
Add sensitive:true to the token hint so the lookup-based restore path
is used for channels.discord.token.
Fix 2 (src/config/redact-snapshot.ts):
In restoreRedactedValuesWithLookup, detect when a sensitive-hinted path
holds a SecretRef object whose id field contains the sentinel, and restore
the whole original value rather than recursing into sub-fields that have
no individual hint entries.
Fix 3 (ui/src/ui/views/config-form.node.ts):
renderTextInput was applying String(value) to the DOM input .value
property. When value is a SecretRef object and the field is revealed,
this produces '[object Object]' which gets written into configForm state
and then sent to config.set. Guard: treat structured (non-primitive)
values as permanently read-only with a 'use Raw mode to edit' placeholder.
Fix 4 (ui/src/ui/views/agents-panels-overview.ts, agents-utils.ts):
Primary model picker on /agents always showed 'Not set' on page
load/refresh. Cause: select.value binding fires before async catalog
options are in the DOM. Fix: add selected attribute to each option
declaratively rather than relying on the parent select .value property.
Greptile SummaryThis PR fixes four related bugs affecting the dashboard config save flow and the agents model picker. The root cause chain is well-diagnosed: a missing Confidence Score: 5/5All four bug fixes are logically sound and well-targeted; no critical issues found. All four described bugs are correctly fixed. The restoreRedactedValuesWithLookup new branch has correct ordering. The isStructuredValue guard in renderTextInput correctly prevents [object Object] corruption. The declarative ?selected bindings fix the async-options race. No P0 or P1 issues remain. No files require special attention.
|
| Filename | Overview |
|---|---|
| extensions/discord/src/config-ui-hints.ts | Adds missing sensitive: true to Discord token hint, fixing the root cause of the SecretRef restore failure. |
| src/config/redact-snapshot.ts | Adds new else-if branch to detect and wholesale-restore a SecretRef object whose id field contains the redacted sentinel. |
| ui/src/ui/views/config-form.node.ts | Guards renderTextInput against structured (object) values being serialized as [object Object] and written back to form state. |
| ui/src/ui/views/agents-utils.ts | Adds declarative ?selected bindings on option elements to fix async catalog-loading race in model picker. |
| ui/src/ui/views/agents-panels-overview.ts | Adds ?selected to empty-value option elements for correct declarative selection state. |
| src/hooks/bundled/bootstrap-alternate-files/handler.ts | New bundled hook that replaces workspace bootstrap file slots with content from external paths; handles errors gracefully. |
| src/agents/workspace.ts | Exports VALID_BOOTSTRAP_NAMES for use by the new bootstrap-alternate-files hook. |
Reviews (2): Last reviewed commit: "fix(review): address greptile feedback o..." | Re-trigger Greptile
| if (params.bearerToken && !baseEnv["AWS_BEARER_TOKEN_BEDROCK"]?.trim()) { | ||
| process.env["AWS_BEARER_TOKEN_BEDROCK"] = params.bearerToken; | ||
| if (baseEnv !== process.env) { | ||
| env = { ...baseEnv, AWS_BEARER_TOKEN_BEDROCK: params.bearerToken }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Bearer token silently stale after first injection
process.env["AWS_BEARER_TOKEN_BEDROCK"] is only written when it is absent (!baseEnv["…"].trim()). On the very first call the value is injected, but on every subsequent catalog refresh baseEnv["AWS_BEARER_TOKEN_BEDROCK"] is already truthy, so the branch is skipped. If the user later rotates the token in their plugin config, params.bearerToken will hold the new value but the guard will prevent it from reaching process.env — the AWS SDK will keep using the stale token until the process restarts.
The intent of the guard is presumably to avoid clobbering a token that was set independently in the real OS environment. A simple way to honour both goals is to also overwrite when the existing value differs from the incoming one:
| if (params.bearerToken && !baseEnv["AWS_BEARER_TOKEN_BEDROCK"]?.trim()) { | |
| process.env["AWS_BEARER_TOKEN_BEDROCK"] = params.bearerToken; | |
| if (baseEnv !== process.env) { | |
| env = { ...baseEnv, AWS_BEARER_TOKEN_BEDROCK: params.bearerToken }; | |
| } | |
| } | |
| if (params.bearerToken && params.bearerToken !== baseEnv["AWS_BEARER_TOKEN_BEDROCK"]?.trim()) { |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/amazon-bedrock/discovery.ts
Line: 252-257
Comment:
**Bearer token silently stale after first injection**
`process.env["AWS_BEARER_TOKEN_BEDROCK"]` is only written when it is absent (`!baseEnv["…"].trim()`). On the very first call the value is injected, but on every subsequent catalog refresh `baseEnv["AWS_BEARER_TOKEN_BEDROCK"]` is already truthy, so the branch is skipped. If the user later rotates the token in their plugin config, `params.bearerToken` will hold the new value but the guard will prevent it from reaching `process.env` — the AWS SDK will keep using the stale token until the process restarts.
The intent of the guard is presumably to avoid clobbering a token that was set independently in the real OS environment. A simple way to honour both goals is to also overwrite when the existing value differs from the incoming one:
```suggestion
if (params.bearerToken && params.bearerToken !== baseEnv["AWS_BEARER_TOKEN_BEDROCK"]?.trim()) {
```
How can I resolve this? If you propose a fix, please make it concise.| region: { | ||
| type: "string", | ||
| description: "AWS region for Bedrock API calls. Defaults to us-east-1.", | ||
| }, | ||
| }, |
There was a problem hiding this comment.
region plugin config property is not wired up
The schema exposes a region field under plugins.entries.amazon-bedrock.config, but extensions/amazon-bedrock/index.ts only reads pluginConfig?.bearerToken — it never reads pluginConfig?.region. The actual region is resolved from config.models.bedrockDiscovery.region (a different config path) inside resolveImplicitBedrockProvider.
A user who sets plugins.entries.amazon-bedrock.config.region expecting it to control the Bedrock region will have that value silently ignored. Either wire it up in index.ts before the resolveImplicitBedrockProvider call, or remove this field from the schema until it is implemented.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/bundled-plugin-metadata.generated.ts
Line: 159-163
Comment:
**`region` plugin config property is not wired up**
The schema exposes a `region` field under `plugins.entries.amazon-bedrock.config`, but `extensions/amazon-bedrock/index.ts` only reads `pluginConfig?.bearerToken` — it never reads `pluginConfig?.region`. The actual region is resolved from `config.models.bedrockDiscovery.region` (a different config path) inside `resolveImplicitBedrockProvider`.
A user who sets `plugins.entries.amazon-bedrock.config.region` expecting it to control the Bedrock region will have that value silently ignored. Either wire it up in `index.ts` before the `resolveImplicitBedrockProvider` call, or remove this field from the schema until it is implemented.
How can I resolve this? If you propose a fix, please make it concise.| it("expands tilde in source paths", async () => { | ||
| // Write a file in tmpDir and create a tilde path that points to it. | ||
| // We can't easily test with a real home dir, so verify the file IS found | ||
| // by providing an absolute path that happens to use home dir prefix. | ||
| const homeDir = os.homedir(); | ||
| const relToHome = path.relative(homeDir, tmpDir); | ||
| // Only run this sub-test if tmpDir is inside home | ||
| if (relToHome.startsWith("..")) { | ||
| return; | ||
| } | ||
| const tildePath = path.join("~", relToHome, "SOUL-tilde.md"); | ||
| const absFile = path.join(tmpDir, "SOUL-tilde.md"); | ||
| await fs.writeFile(absFile, "# Tilde Soul", "utf-8"); | ||
|
|
||
| const context = makeContext( | ||
| [makeBootstrapFile("SOUL.md", { missing: true })], | ||
| createAlternateConfig({ "SOUL.md": tildePath }), | ||
| ); | ||
|
|
||
| await handler(createHookEvent("agent", "bootstrap", "agent:main:main", context)); | ||
|
|
||
| expect(context.bootstrapFiles[0]?.content).toBe("# Tilde Soul"); | ||
| }); | ||
|
|
||
| it("handles multiple replacements in a single pass", async () => { | ||
| const soulFile = path.join(tmpDir, "SOUL-multi.md"); | ||
| const identFile = path.join(tmpDir, "IDENTITY-multi.md"); |
There was a problem hiding this comment.
Silent no-op test when
tmpDir is outside home dir
When relToHome.startsWith("..") the test body returns without any assertions — it passes unconditionally, giving no signal that tilde expansion was not exercised. CI will show a green checkmark even though the feature was never verified.
Use Vitest's conditional skip instead:
| it("expands tilde in source paths", async () => { | |
| // Write a file in tmpDir and create a tilde path that points to it. | |
| // We can't easily test with a real home dir, so verify the file IS found | |
| // by providing an absolute path that happens to use home dir prefix. | |
| const homeDir = os.homedir(); | |
| const relToHome = path.relative(homeDir, tmpDir); | |
| // Only run this sub-test if tmpDir is inside home | |
| if (relToHome.startsWith("..")) { | |
| return; | |
| } | |
| const tildePath = path.join("~", relToHome, "SOUL-tilde.md"); | |
| const absFile = path.join(tmpDir, "SOUL-tilde.md"); | |
| await fs.writeFile(absFile, "# Tilde Soul", "utf-8"); | |
| const context = makeContext( | |
| [makeBootstrapFile("SOUL.md", { missing: true })], | |
| createAlternateConfig({ "SOUL.md": tildePath }), | |
| ); | |
| await handler(createHookEvent("agent", "bootstrap", "agent:main:main", context)); | |
| expect(context.bootstrapFiles[0]?.content).toBe("# Tilde Soul"); | |
| }); | |
| it("handles multiple replacements in a single pass", async () => { | |
| const soulFile = path.join(tmpDir, "SOUL-multi.md"); | |
| const identFile = path.join(tmpDir, "IDENTITY-multi.md"); | |
| it.skipIf(path.relative(os.homedir(), tmpDir).startsWith(".."))("expands tilde in source paths", async () => { |
or split the condition into beforeEach with it.skip(), so a skipped run is visible in the test report rather than silently passing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/bundled/bootstrap-alternate-files/handler.test.ts
Line: 189-215
Comment:
**Silent no-op test when `tmpDir` is outside home dir**
When `relToHome.startsWith("..")` the test body returns without any assertions — it passes unconditionally, giving no signal that tilde expansion was not exercised. CI will show a green checkmark even though the feature was never verified.
Use Vitest's conditional skip instead:
```suggestion
it.skipIf(path.relative(os.homedir(), tmpDir).startsWith(".."))("expands tilde in source paths", async () => {
```
or split the condition into `beforeEach` with `it.skip()`, so a skipped run is visible in the test report rather than silently passing.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f6f05b88b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (params.bearerToken && !baseEnv["AWS_BEARER_TOKEN_BEDROCK"]?.trim()) { | ||
| process.env["AWS_BEARER_TOKEN_BEDROCK"] = params.bearerToken; |
There was a problem hiding this comment.
Avoid mutating process env with plugin bearer token
Writing params.bearerToken into process.env creates sticky global state that survives later config changes. If a user removes plugins.entries.amazon-bedrock.config.bearerToken, this value is never cleared, so subsequent discovery runs can continue authenticating with stale credentials and keep Bedrock implicitly enabled until process restart.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 64a3b9142f. When params.bearerToken is absent but a plugin-injected value exists in process.env, we now delete it so removing the config from plugin.entries.amazon-bedrock.config actually clears bearer-token auth on the next catalog run.
| const pluginConfig = ctx.config.plugins?.entries?.["amazon-bedrock"]?.config as | ||
| | Record<string, unknown> | ||
| | undefined; | ||
| const bearerToken = normalizeSecretInputString(pluginConfig?.bearerToken) ?? undefined; |
There was a problem hiding this comment.
Resolve SecretRef bearer tokens before normalization
The plugin now advertises bearerToken as string | object (SecretRef), but this path only calls normalizeSecretInputString, which returns undefined for objects. That means SecretRef-configured bearer tokens are silently ignored during Bedrock discovery, so users who follow the new schema/description will still fail auth unless they use a plain string or external env var.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 64a3b9142f. Added inline env-source SecretRef resolution in index.ts: if bearerTokenRaw is an object with source: "env", we read ctx.env[ref.id] directly. File/exec refs fall through to undefined in non-interactive catalog runs — env/credential discovery still works in that case.
- extensions/amazon-bedrock/discovery.ts: Fix stale bearer token after first injection. The guard previously skipped injection when the env var was already set, meaning a rotated token in plugin config would never reach the AWS SDK without a process restart. Changed to overwrite when the incoming token differs from the current env value. - extensions/amazon-bedrock/discovery.ts + index.ts: Wire up the region plugin config field. The schema exposed plugins.entries.amazon-bedrock .config.region but index.ts never passed it to resolveImplicitBedrockProvider, so the field was silently ignored. Now read and forwarded, taking precedence over config.models.bedrockDiscovery.region and env vars. - src/hooks/bundled/bootstrap-alternate-files/handler.test.ts: Replace silent early-return guard in tilde-expansion test with it.skipIf() so the test shows as skipped (not silently passing) when tmpDir is outside the home directory.
|
Thanks for the review @greptile-apps — addressed all three issues in the follow-up commit Bearer token stale after first injection — Fixed. Changed the guard from
Silent no-op test for tilde expansion — Fixed. Replaced the early |
extensions/amazon-bedrock/index.ts (P2 - SecretRef bearer tokens silently ignored): normalizeSecretInputString returns undefined for objects, so SecretRef- configured bearer tokens were silently dropped. Added inline resolution for env-source SecretRefs by reading ctx.env[ref.id] directly. File/exec refs are not supported in non-interactive catalog runs and fall through to undefined, allowing env/credential discovery to proceed as normal. extensions/amazon-bedrock/discovery.ts (P1 - process.env mutation is sticky): The previous fix updated process.env only when a token was provided. If a user later removes bearerToken from plugin config, the stale value would remain in process.env indefinitely. Now: when params.bearerToken is absent but a plugin-injected value is present in process.env, we clear it so that removing the config actually stops bearer-token auth on the next catalog run.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64a3b9142f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } else if (currentEnvToken && baseEnv === process.env) { | ||
| // Token was removed from plugin config — clear the stale env var so the | ||
| // AWS SDK does not continue authenticating with it. | ||
| delete process.env["AWS_BEARER_TOKEN_BEDROCK"]; |
There was a problem hiding this comment.
Preserve env Bedrock token when plugin token is unset
This branch removes process.env.AWS_BEARER_TOKEN_BEDROCK whenever params.bearerToken is missing, regardless of whether the value came from plugin config or the operator environment. In normal catalog runs params.env is process.env, so users relying on AWS_BEARER_TOKEN_BEDROCK lose their credential on the first discovery pass, hasAwsCreds can flip false, and implicit Bedrock discovery may be disabled until restart. The cleanup should only clear a token that was explicitly injected by plugin config, not a pre-existing env credential.
Useful? React with 👍 / 👎.
| return options.map((option) => html`<option value=${option.value}>${option.label}</option>`); | ||
| return options.map( | ||
| (option) => | ||
| html`<option value=${option.value} ?selected=${option.value === current}> |
There was a problem hiding this comment.
Avoid preselecting inherited model as explicit override
buildModelOptions now marks every option selected when option.value === current, but current is passed as effectivePrimary (which includes inherited defaults). For non-default agents with no explicit primary, this makes a concrete model option selected at the same time as the empty “Inherit default” option, and single-select behavior resolves to the concrete option, so inherited agents can appear explicitly pinned after reload. Selection marking should use the explicit override value, not the effective inherited value.
Useful? React with 👍 / 👎.
|
Thanks @bgrubin for this PR and for grouping the related UI/config failures. #58044 (merged) partially addresses this PR: the SecretRef config-save corruption path is now fixed (round-trip restore hardening, unsafe raw fallback handling, and write-path safeguards). We’re leaving this PR open for the remaining scope: the separate primary-model picker “Not set” rendering issue described here. |
|
Closing this as implemented after Codex review. Current What I checked:
So I’m closing this as already implemented rather than keeping a duplicate issue open. Review notes: reviewed against 5dfc1b90e176; fix evidence: release v2026.3.31, commit 5dfc1b90e176. |
Problem
Any
config.setfrom the dashboard was rejected with:Also: the Primary model picker on
/agentsalways showed "Not set" after page load or refresh.Root Cause
The Discord
tokenui-hint was missingsensitive: true. This causedrestoreRedactedValuesto use the pattern-guessing path, which checkedisSensitivePath('channels.discord.token.id')— false, since the path ends inidnottoken. The__OPENCLAW_REDACTED__sentinel inside the SecretRef'sidfield was never restored before Zod validation, so validation failed.Fixes
Fix 1 (
extensions/discord/src/config-ui-hints.ts):Add
sensitive: trueto the Discord token hint so the lookup-based restore path is used.Fix 2 (
src/config/redact-snapshot.ts):In
restoreRedactedValuesWithLookup, detect when a sensitive-hinted path holds a SecretRef object whoseidfield contains the redacted sentinel, and restore the whole original value rather than recursing into sub-fields that have no individual hint entries. Mirrors the asymmetric behavior inredactSecretRefId.Fix 3 (
ui/src/ui/views/config-form.node.ts):renderTextInputwas callingString(value)on the DOM input's.valueproperty. Whenvalueis a SecretRef object and the field is revealed, this produces[object Object]which gets written intoconfigFormstate and sent toconfig.set. Guard: treat structured (non-primitive) values as permanently read-only with a "use Raw mode to edit" placeholder, and suppress the reveal toggle.Fix 4 (
ui/src/ui/views/agents-panels-overview.ts,agents-utils.ts):The Primary model picker on
/agentsalways showed "Not set" after load/refresh. Cause:<select>.valuebinding fires before async catalog options are in the DOM. Fix: add?selected=${value === current}to each<option>so selection is set declaratively rather than relying on the parent<select>'s.valueproperty.