Public: anyone can read this; the crew can change it.

Absorb: keep secret-type feature settings #9909

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A feature setting can now be declared `type: "secret"`; the forge keeps it sealed, never shows it again, and hands it to the app as `FORGE_SETTING_*`. The tests I ran pass: `test/features.test.ts`, `test/apps-features.test.ts`, `test/features-tui.test.ts` and `test/mcp-size.test.ts`. `bun x tsc --noEmit`, `bun scripts/gate.ts` and `bun scripts/design-lint.ts` are clean. I did not run the full suit…

shipped production is serving it deploy f21b126

opened this

A feature setting can now be declared type: "secret"; the forge keeps it sealed, never shows it again, and hands it to the app as FORGE_SETTING_*. The tests I ran pass: test/features.test.ts, test/apps-features.test.ts, test/features-tui.test.ts and test/mcp-size.test.ts. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. I did not run the full suite; the forge's check does that on the push. I did not call keep_plan; it isn't available in this session.

What changed

  • Storage: a secret is sealed with the forge key, tied to its app and setting name, so a row copied elsewhere won't open. src/apps/feature_env.ts seals and opens it, and featureVarsAt now takes dataDir.
  • Never shown again: the feature view, GET /api/v1/features/:slug and the MCP features tool return an empty value for a secret and say only whether one is kept. The settings page draws a password field with a "kept; type to replace" placeholder.
  • Declaring one: a secret may not have a default, because forge.json is not a place for one.
  • Saving: a blank form field keeps the stored secret. Through the API or MCP, an empty string clears it. The app.feature_settings event names only the key.
  • MCP: it is an existing type on the existing features tool, so no tool was added and the size ceilings are untouched.
  • Docs and tests: docs/features/absorb.md is updated, and a new test covers sealing, hiding and the blank-keeps rule. An old parse test used "secret" as its example of an invalid type, so I changed it to "password"; the test was what was wrong.

Left out

  • Existing previews: a secret is still handed only to a preview the forge would already give settings to, so an unapproved guest pull gets none.
  • Lost key: if the forge key is lost or a row is altered, the secret falls back to empty rather than to ciphertext.
  • No rotation or audit-read trail: I did not add a key rotation path or a "last read" record like the forge-level secret store has.

Closes #9886

commented

A feature setting can now be declared type: "secret"; the forge keeps it sealed, never shows it again, and hands it to the app as FORGE_SETTING_*. The tests I ran pass: test/features.test.ts, test/apps-features.test.ts, test/features-tui.test.ts and test/mcp-size.test.ts. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. I did not run the full suite; the forge's check does that on the push. I did not call keep_plan; it isn't available in this session.

What changed

  • Storage: a secret is sealed with the forge key, tied to its app and setting name, so a row copied elsewhere won't open. src/apps/feature_env.ts seals and opens it, and featureVarsAt now takes dataDir.
  • Never shown again: the feature view, GET /api/v1/features/:slug and the MCP features tool return an empty value for a secret and say only whether one is kept. The settings page draws a password field with a "kept; type to replace" placeholder.
  • Declaring one: a secret may not have a default, because forge.json is not a place for one.
  • Saving: a blank form field keeps the stored secret. Through the API or MCP, an empty string clears it. The app.feature_settings event names only the key.
  • MCP: it is an existing type on the existing features tool, so no tool was added and the size ceilings are untouched.
  • Docs and tests: docs/features/absorb.md is updated, and a new test covers sealing, hiding and the blank-keeps rule. An old parse test used "secret" as its example of an invalid type, so I changed it to "password"; the test was what was wrong.

Left out

  • Existing previews: a secret is still handed only to a preview the forge would already give settings to, so an unapproved guest pull gets none.
  • Lost key: if the forge key is lost or a row is altered, the secret falls back to empty rather than to ciphertext.
  • No rotation or audit-read trail: I did not add a key rotation path or a "last read" record like the forge-level secret store has.
commented

The check failed on a missing commit-message line, not on the code. I added an empty commit with a Try: line and pushed it. I haven't seen the check run again, and I didn't re-run the tests, since nothing in the code changed.

Feature settings can now be declared type: "secret". The forge keeps the value sealed and passes it to the app as FORGE_SETTING_*. The settings page, the API and the MCP features tool never show it again; they only say whether one is kept. A secret can't have a default in forge.json. A blank form field keeps the stored value, and an empty string sent through the API or MCP clears it.

To try it on the preview, absorb an app whose forge.json declares a secret setting and open /settings/features/<slug>. Save a value. The field stays blank and shows "kept; type to replace".

I added a test for sealing, hiding and the blank-keeps rule. The MCP features tool gained a type, not a new tool, so the size ceilings are untouched. I did not add key rotation.

Changes requested — before the latest push

Sealing, hiding in the view/API/MCP, the no-default rule and the page's blank-keeps rule look sound. Two things to fix before this lands.

  1. HIGH-ish, the terminal door is not updated (src/tui/screens/featureswitch.ts, unchanged in this diff). The settings screen's prompt for a secret is an ordinary visible text field, so a person types the secret in the clear. Its row shows empty for a kept secret, because value is now blank. And submitting an empty prompt sends "" to changeFeatureSettings, which clears the kept secret. The page treats blank as keep, so the two doors disagree and one slip in the terminal wipes the secret. Fix: for type === "secret" the row meta says kept or not set from s.set, the prompt masks input if the prompt screen supports it, and a blank submit is a no-op (or asks first). Add a test in test/features-tui.test.ts that fails without it.
  1. Blank means clear on the API and MCP, but set stays true afterward. checkSetting accepts "" for a secret and sealSetting seals the empty string, so set reports a secret as kept when it is empty. Fix: in changeFeatureSettings, a "" for a secret deletes the row instead of sealing it. Test that the view then says not set.

Also: docs/features/absorb.md should say how a blank behaves in each door once they agree. Nothing was added to README, which is correct.

commented

Both of sentinel's points are fixed and pushed to the same branch. tsc, scripts/gate.ts and test/features.test.ts plus test/features-tui.test.ts pass (34 tests). I did not run the full suite or the design lint, since nothing under src/web/ changed.

  • Terminal door (src/tui/screens/featureswitch.ts): a secret's row now says kept or not set, taken from s.set. The prompt masks what is typed and starts empty. A blank submit changes nothing and toasts "left as it was", matching the page. The new test in test/features-tui.test.ts checks the typed secret is not on screen, the stored value is sealed, and a blank Enter leaves it unchanged.
  • Blank clears on API and MCP: in changeFeatureSettings, a "" for a secret now deletes the row instead of sealing the empty string. I added clearFeatureSettings in src/core/features.ts for that. The test in test/features.test.ts sends a blank through the API and checks the view reports set: false and the row is gone.
  • Docs: docs/features/absorb.md now says a blank keeps the secret on the page and in the terminal, clears it on the API and MCP, and that the terminal masks input and shows kept or not set.

The terminal has no way to clear a secret. That choice was mine: it keeps a slip from wiping the secret, and clearing stays with the API and MCP.

I did not confirm the new terminal test fails without the fix.

commented

I merged main into the branch, resolved the two conflicts, and pushed. tsc, scripts/gate.ts and the three feature test files (41 tests) pass. I did not run the full suite.

  • src/actions/features.ts: both sides had added an import on the same line, so I kept both, the secret-setting one and the tool-spec one.
  • docs/features/absorb.md: main added an "MCP tools" section, so I kept it. In the "Not done yet" paragraph I dropped secret settings (#9886), which this pull does. I also dropped #9885, the MCP tools in /mcp, since main's new section covers the tools.

The earlier secret-setting changes are unchanged.

Changes requested — before the latest push

Sealing, hiding in view/API/MCP, the no-default rule, the terminal door (masked, kept/not set, blank no-op) and the tests are sound. Two things to fix in one round.

  1. (first) The page shows whether a secret is kept only in a placeholder. Placeholders disappear on typing, have low contrast, and are not reliably announced, so a screen-reader or keyboard user cannot tell kept from not set. Show it as visible text next to the field (e.g. a muted "Kept" / "Not set" line, tied with aria-describedby), and add a page test that asserts it for both states. Also say on the page how to clear (or that clearing is the API/MCP), since the page offers no way to remove a secret.
  1. Direction: the owner said PIPELINE FIRST (#6534, #6611, #5276, #3316, #6217, #6428, #1242, #1073 before any feature). This is a feature (#9886) and is not on that list. If it is already queued and the pipeline pulls are not blocked by it, say so in the pull; otherwise it waits.

Also: a secret is still handed to every preview of the app, and a pull author controls the preview code, so docs/features/absorb.md should say plainly that a secret reaches previews of the app (as the plain settings paragraph does), not only imply it. Minor: keep the core/features.ts import list alphabetical (clearFeatureSettings).

commented

A feature setting can now have type secret. It is kept sealed (AES-GCM, bound to the app and the setting's name) and handed to the app as FORGE_SETTING_*. After saving, no surface shows the value: the page, API, MCP and terminal say only whether one is kept. forge.json may not declare a default for a secret.

What a blank does, by surface:

  • Page: a blank password field keeps the kept secret. Beside the field, muted text says Kept or Not set, linked to the input with aria-describedby. The same text says typing replaces the secret, a blank keeps it, and clearing is done through the API or MCP.
  • Terminal: the prompt masks what is typed and the row says kept or not set. A blank submit changes nothing.
  • API and MCP: a blank ("") clears the secret by deleting its row, so it then reads as not set rather than as a sealed empty string.

docs/features/absorb.md says how a blank behaves on each surface. It also says plainly that a secret reaches every preview that gets settings, so code in a pull can read it.

Ordering against pipeline-first: #9886 is one item of #9771 and was already queued. None of #6534, #6611, #5276, #3316, #6217, #6428, #1242 or #1073 waits on it. Most of them wait on the owner's later-gate issue #595, so this pull does not hold up the pipeline work.

Left out on purpose: the page and terminal have no clear control, so one slip cannot wipe a secret.

Where to look:

  • src/actions/features.ts (changeFeatureSettings)
  • src/web/pages/features.ts (the field and its status text)
  • src/tui/screens/featureswitch.ts

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, bun scripts/design-lint.ts, and bun test on test/features.test.ts, test/features-tui.test.ts and test/feature-tools.test.ts (41 pass). The tests cover:

  • a secret never appears in the API response, stored row, page or events;
  • a blank on the page keeps it;
  • a blank through the API clears it, and the page then says Not set;
  • the page shows Kept and aria-describedby;
  • the terminal masks input, shows kept and ignores a blank submit.

To try it: add "apiToken": { "type": "secret", "label": "API token" } to a feature's settings, absorb the app, and open /settings/features/<slug>.

🤖 Generated with Claude Code

commented

A feature setting can now have type secret. It is kept sealed (AES-GCM, bound to the app and the setting's name) and handed to the app as FORGE_SETTING_*. After saving, no surface shows the value: the page, API, MCP and terminal say only whether one is kept. forge.json may not declare a default for a secret.

What a blank does, by surface:

  • Page: a blank password field keeps the kept secret. Beside the field, muted text says Kept or Not set, linked to the input with aria-describedby. The same text says typing replaces the secret, a blank keeps it, and clearing is done through the API or MCP.
  • Terminal: the prompt masks what is typed and the row says kept or not set. A blank submit changes nothing.
  • API and MCP: a blank ("") clears the secret by deleting its row, so it then reads as not set rather than as a sealed empty string.

docs/features/absorb.md says how a blank behaves on each surface. It also says plainly that a secret reaches every preview that gets settings, so code in a pull can read it.

Latest merge of main: the one conflict was the "Not done yet" line in docs/features/absorb.md. main had removed /system health (#9887) and this pull removes secret settings (#9886), so only the schedule (#9883) and feed items (#9884) remain.

Ordering against pipeline-first: #9886 is one item of #9771 and was already queued. None of #6534, #6611, #5276, #3316, #6217, #6428, #1242 or #1073 waits on it. Most of them wait on the owner's later-gate issue #595, so this pull does not hold up the pipeline work.

Left out on purpose: the page and terminal have no clear control, so one slip cannot wipe a secret.

Where to look:

  • src/actions/features.ts (changeFeatureSettings)
  • src/web/pages/features.ts (the field and its status text)
  • src/tui/screens/featureswitch.ts

Checks run: after the merge, bun scripts/gate.ts, bun x tsc --noEmit, bun scripts/design-lint.ts, and bun test on test/features.test.ts, test/features-tui.test.ts and test/feature-tools.test.ts (41 pass). The tests cover:

  • a secret never appears in the API response, stored row, page or events;
  • a blank on the page keeps it;
  • a blank through the API clears it, and the page then says Not set;
  • the page shows Kept and aria-describedby;
  • the terminal masks input, shows kept and ignores a blank submit.

To try it: add "apiToken": { "type": "secret", "label": "API token" } to a feature's settings, absorb the app, and open /settings/features/<slug>.

🤖 Generated with Claude Code

commented

Absorbing an app now keeps its secret-type feature settings (#9886). On the web page and in the terminal, the field is masked, shows "Kept" or "Not set" in text tied to the field, and says how to clear it. Submitting it blank leaves the stored value as it was, the plain value is never stored, and the page says previews get the setting too.

This update merges main, which had just added feature feeds (#9884), and resolves two conflicts:

  • docs/features/absorb.md: each side's "Not done yet" line still listed what the other side has now finished. The merged line lists only the schedule (#9883) as still to do.
  • test/features-tui.test.ts: both sides' tests are kept. The secret-setting test runs first and un-absorbs its app at the end, then main's two feed tests absorb "radar" from scratch.

Checks: bun scripts/gate.ts and bun x tsc --noEmit pass, and so do test/features.test.ts, test/features-tui.test.ts and test/palette-roster.test.ts (62 tests). scripts/fast.ts picked 1,823 test files for the merge, far over its 5-minute budget, so I ran the tests the merge touches instead.

To try it, open Settings › Features, choose an absorbed app with a secret setting, save a value, then submit the field blank: it still shows "Kept". In the terminal, the "Features" palette entry does the same, and the typed value never appears on screen.

🤖 Generated with Claude Code

Approved

Earlier asks are met: the terminal masks a secret, shows kept or not set and ignores a blank submit. A blank through the API or MCP deletes the row, so the view reads not set. absorb.md says how a blank behaves in each door. A secret is sealed per app and name, never returned, absent from events, and has no default. The page is a password field with Kept or Not set tied to it by aria-describedby, and it works without JavaScript. design-lint is clean and README is untouched. I did not run the tests or check the preview; that rests on the green check and the diff.

Two things I would do next:

  1. Non-blocking: give the page and terminal a deliberate clear (a separate post-form button with a confirm step). Today only the API or MCP can clear a secret, so a keyboard-only person with no token has to replace it instead.
  2. Non-blocking: say on the page, near the field, that previews of the app can read the secret. absorb.md says it, but the person typing it will not see that.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs