@pellux/goodvibes-tui 1.24.2 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +18234 -12253
  5. package/package.json +8 -7
  6. package/src/cli/help.ts +10 -0
  7. package/src/cli/redaction.ts +68 -3
  8. package/src/config/goodvibes-home.ts +149 -0
  9. package/src/config/secret-config.ts +64 -8
  10. package/src/core/turn-event-wiring.ts +7 -16
  11. package/src/core/workstream-notification.ts +69 -0
  12. package/src/daemon/cli.ts +62 -7
  13. package/src/daemon/handlers/credentials.ts +15 -5
  14. package/src/daemon/handlers/index.ts +7 -6
  15. package/src/daemon/send/channels.ts +200 -0
  16. package/src/daemon/send/command.ts +332 -0
  17. package/src/daemon/send/composition.ts +99 -0
  18. package/src/daemon/send/failure-text.ts +93 -0
  19. package/src/daemon/send/inert-text.ts +225 -0
  20. package/src/daemon/send/stdin.ts +24 -0
  21. package/src/input/commands/calendar-runtime.ts +245 -0
  22. package/src/input/commands/config.ts +68 -2
  23. package/src/input/commands/connection-status.ts +258 -0
  24. package/src/input/commands/local-provider-runtime.ts +9 -1
  25. package/src/input/commands/mail-runtime.ts +267 -0
  26. package/src/input/commands/operator-runtime.ts +6 -1
  27. package/src/input/commands/payment-card-intake.ts +200 -0
  28. package/src/input/commands/profile-render.ts +250 -0
  29. package/src/input/commands/profile-runtime.ts +362 -0
  30. package/src/input/commands/profile-types.ts +381 -0
  31. package/src/input/commands/review-runtime.ts +13 -1
  32. package/src/input/commands.ts +11 -0
  33. package/src/input/composer-editor.ts +4 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  35. package/src/input/payments-config.ts +167 -0
  36. package/src/input/provider-key-intake.ts +10 -2
  37. package/src/input/settings-modal-commit.ts +146 -0
  38. package/src/input/settings-modal-connections.ts +102 -0
  39. package/src/input/settings-modal-data.ts +30 -0
  40. package/src/input/settings-modal-reset.ts +6 -2
  41. package/src/input/settings-modal-secrets.ts +9 -2
  42. package/src/input/settings-modal-types.ts +19 -3
  43. package/src/input/settings-modal.ts +54 -107
  44. package/src/main.ts +7 -2
  45. package/src/panels/modals/marketplace-modal.ts +5 -0
  46. package/src/panels/modals/skills-modal.ts +5 -0
  47. package/src/renderer/settings-modal-connections.ts +63 -0
  48. package/src/renderer/settings-modal-helpers.ts +6 -0
  49. package/src/renderer/settings-modal.ts +21 -1
  50. package/src/runtime/bootstrap-core.ts +1 -1
  51. package/src/runtime/bootstrap.ts +2 -2
  52. package/src/runtime/context.ts +9 -0
  53. package/src/runtime/credential-composition.ts +54 -0
  54. package/src/runtime/daemon-handler-composition.ts +0 -4
  55. package/src/runtime/install-self-check-startup.ts +23 -0
  56. package/src/runtime/mail-composition.ts +65 -0
  57. package/src/runtime/onboarding/types.ts +6 -1
  58. package/src/runtime/path-shadow-startup.ts +243 -0
  59. package/src/runtime/path-shadow.ts +418 -0
  60. package/src/runtime/reachability-notice.ts +100 -0
  61. package/src/runtime/runtime-services-types.ts +243 -0
  62. package/src/runtime/services.ts +30 -224
  63. package/src/shell/ui-openers.ts +7 -0
  64. package/src/verification/verification-ledger.ts +158 -0
  65. package/src/version.ts +1 -1
  66. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  67. package/src/daemon/handlers/calendar/ics.ts +0 -556
  68. package/src/daemon/handlers/calendar/index.ts +0 -316
  69. package/src/daemon/handlers/email/config.ts +0 -164
  70. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  71. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  72. package/src/daemon/handlers/email/index.ts +0 -43
  73. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  74. package/src/daemon/handlers/email/runtime.ts +0 -140
  75. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  76. package/src/daemon/handlers/email/validation.ts +0 -147
  77. package/src/daemon/handlers/email/write-handlers.ts +0 -133
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { LLMProvider } from '@pellux/goodvibes-sdk/platform/providers';
15
+ import type { SecretScope } from '../config/secrets.ts';
15
16
  import type { ConcealedInputRequest } from './concealed-input.ts';
16
17
  import type { CommandContext } from './command-registry.ts';
17
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -58,7 +59,7 @@ export interface ProviderKeyIntakeDeps {
58
59
  readonly provider: LLMProvider | undefined;
59
60
  /** Canonical secret store — the same path /secrets set writes through. */
60
61
  readonly secretsManager?: {
61
- set(key: string, value: string, options?: { scope?: 'project' | 'user'; medium?: 'secure' | 'plaintext' }): Promise<void>;
62
+ set(key: string, value: string, options?: { scope?: SecretScope; medium?: 'secure' | 'plaintext' }): Promise<void>;
62
63
  } | undefined;
63
64
  /** Re-resolves credentials and re-registers providers live (no restart). */
64
65
  readonly refreshProviderCredentials?: (() => Promise<void>) | undefined;
@@ -112,7 +113,14 @@ export function runProviderKeyIntake(
112
113
  }
113
114
  void (async () => {
114
115
  try {
115
- await secretsManager.set(secretKey, value, { scope: 'user', medium: 'secure' });
116
+ // Daemon scope, not user scope. The DAEMON is the process that runs
117
+ // the model — it answers Telegram, ntfy, mail and every scheduled
118
+ // run with this TUI closed. A model key filed in this surface's own
119
+ // tier is readable only while the TUI is open, so the daemon has no
120
+ // provider to call and cannot answer at all. Daemon tier is the one
121
+ // home every surface and the daemon read, so a key entered here works
122
+ // everywhere, which is the point of configuring it once.
123
+ await secretsManager.set(secretKey, value, { scope: 'daemon', medium: 'secure' });
116
124
  await deps.refreshProviderCredentials?.();
117
125
  deps.print(`Stored the API key for ${label} and registered it live (value hidden). No restart needed.`);
118
126
  } catch (error) {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * settings-modal-commit.ts — commitEdit(), extracted out of the SettingsModal
3
+ * class body so the class file can stay under the repo's architecture
4
+ * line-count gate without trimming arbitrary lines to clear the number.
5
+ *
6
+ * Same dependency-injection shape as settings-modal-activation.ts /
7
+ * settings-modal-adjustment.ts / settings-modal-reset.ts: explicit getters
8
+ * and setter callbacks instead of a bound `this`, so the logic is
9
+ * unit-testable without a live SettingsModal instance and the class method
10
+ * stays a thin delegator.
11
+ */
12
+
13
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
14
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
+ import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
16
+ import { isSecretConfigKey } from '../config/secret-config.ts';
17
+ import { setSecretBackedSettingValue, type SettingsSecretsManager } from './settings-modal-secrets.ts';
18
+ import { buildMcpEntries } from './settings-modal-data.ts';
19
+ import { isWorktreeSetupListConfigKey, parseWorktreeSetupListInput } from './worktree-setup-config.ts';
20
+ import { isSandboxExecListConfigKey, parseSandboxExecListInput } from './sandbox-exec-config.ts';
21
+ import { isExecEnvScrubAllowlistConfigKey, parseExecEnvScrubAllowlistInput } from './exec-env-scrub-config.ts';
22
+ import type { McpEntry, SettingEntry } from './settings-modal-types.ts';
23
+
24
+ export interface CommitEditContext {
25
+ readonly editingMode: boolean;
26
+ readonly currentCategory: string;
27
+ readonly editBuffer: string;
28
+ readonly configManager: ConfigManager | null;
29
+ readonly secretsManager: SettingsSecretsManager | null;
30
+ readonly mcpRegistry: McpRegistry | null;
31
+ readonly mcpAllowAllConfirmationTarget: string | null;
32
+ getSelectedMcp(): McpEntry | null;
33
+ getSelected(): SettingEntry | null;
34
+ setValue(key: ConfigKey, value: unknown): void;
35
+ setEditingMode(value: boolean): void;
36
+ setEditBuffer(value: string): void;
37
+ setMcpEntries(entries: McpEntry[]): void;
38
+ setMcpAllowAllConfirmationTarget(value: string | null): void;
39
+ }
40
+
41
+ /**
42
+ * Commit the current editBuffer to the config. Returns true on success,
43
+ * false if validation failed (the caller always clears editingMode/editBuffer
44
+ * regardless — a failed commit never leaves stale edit state behind).
45
+ */
46
+ export function commitEditValue(ctx: CommitEditContext): boolean {
47
+ if (!ctx.editingMode) return false;
48
+
49
+ if (ctx.currentCategory === 'mcp') {
50
+ const entry = ctx.getSelectedMcp();
51
+ if (!entry || !ctx.mcpRegistry) return false;
52
+ if (ctx.mcpAllowAllConfirmationTarget) {
53
+ const expected = `ALLOW ALL ${ctx.mcpAllowAllConfirmationTarget}`;
54
+ if (ctx.editBuffer.trim() !== expected) {
55
+ return false;
56
+ }
57
+ ctx.mcpRegistry.setServerTrustMode(entry.name, 'allow-all');
58
+ ctx.setMcpEntries(buildMcpEntries(ctx.mcpRegistry));
59
+ ctx.setEditingMode(false);
60
+ ctx.setEditBuffer('');
61
+ ctx.setMcpAllowAllConfirmationTarget(null);
62
+ return true;
63
+ }
64
+
65
+ const nextMode = ctx.editBuffer.trim() as McpEntry['trustMode'];
66
+ const validModes: McpEntry['trustMode'][] = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'];
67
+ if (!validModes.includes(nextMode)) {
68
+ ctx.setEditingMode(false);
69
+ ctx.setEditBuffer('');
70
+ ctx.setMcpAllowAllConfirmationTarget(null);
71
+ return false;
72
+ }
73
+ if (nextMode === 'allow-all' && entry.trustMode !== 'allow-all') {
74
+ ctx.setMcpAllowAllConfirmationTarget(entry.name);
75
+ ctx.setEditBuffer('');
76
+ return false;
77
+ }
78
+ ctx.mcpRegistry.setServerTrustMode(entry.name, nextMode);
79
+ ctx.setMcpEntries(buildMcpEntries(ctx.mcpRegistry));
80
+ ctx.setEditingMode(false);
81
+ ctx.setEditBuffer('');
82
+ ctx.setMcpAllowAllConfirmationTarget(null);
83
+ return true;
84
+ }
85
+
86
+ const entry = ctx.getSelected();
87
+ if (!entry || !ctx.configManager) return false;
88
+
89
+ const { setting } = entry;
90
+ let parsed: unknown = ctx.editBuffer;
91
+
92
+ if (setting.type === 'number') {
93
+ parsed = Number(ctx.editBuffer);
94
+ if (isNaN(parsed as number)) {
95
+ ctx.setEditingMode(false);
96
+ ctx.setEditBuffer('');
97
+ return false;
98
+ }
99
+ }
100
+
101
+ if (setting.type === 'object') {
102
+ // Object-typed keys (e.g. pricing.modelPrices) edit as JSON; the
103
+ // schema's validate() below still rules on the parsed shape.
104
+ try {
105
+ parsed = JSON.parse(ctx.editBuffer);
106
+ } catch {
107
+ ctx.setEditingMode(false);
108
+ ctx.setEditBuffer('');
109
+ return false;
110
+ }
111
+ }
112
+
113
+ if (setting.validate && !setting.validate(parsed)) {
114
+ ctx.setEditingMode(false);
115
+ ctx.setEditBuffer('');
116
+ return false;
117
+ }
118
+
119
+ if (isWorktreeSetupListConfigKey(setting.key)) {
120
+ // Comma-separated display/edit convention for the array-backed
121
+ // worktree.setup.* keys — see worktree-setup-config.ts.
122
+ ctx.setValue(setting.key, parseWorktreeSetupListInput(ctx.editBuffer));
123
+ } else if (isSandboxExecListConfigKey(setting.key)) {
124
+ // Same comma-separated convention for the array-backed
125
+ // sandbox.egressAllowlist / sandbox.workspaceWritable keys — see
126
+ // sandbox-exec-config.ts.
127
+ ctx.setValue(setting.key, parseSandboxExecListInput(ctx.editBuffer));
128
+ } else if (isExecEnvScrubAllowlistConfigKey(setting.key)) {
129
+ // Same comma-separated convention for permissions.execEnvScrubAllowlist
130
+ // — see exec-env-scrub-config.ts.
131
+ ctx.setValue(setting.key, parseExecEnvScrubAllowlistInput(ctx.editBuffer));
132
+ } else if (setting.type === 'string' && isSecretConfigKey(setting.key)) {
133
+ setSecretBackedSettingValue({
134
+ key: setting.key,
135
+ value: String(parsed ?? ''),
136
+ configManager: ctx.configManager,
137
+ secretsManager: ctx.secretsManager,
138
+ setConfigValue: (key, value) => ctx.setValue(key, value),
139
+ });
140
+ } else {
141
+ ctx.setValue(setting.key as ConfigKey, parsed);
142
+ }
143
+ ctx.setEditingMode(false);
144
+ ctx.setEditBuffer('');
145
+ return true;
146
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * settings-modal-connections.ts — the Connections category of the settings
3
+ * workspace: whether mail and calendar are actually usable, and if not, the
4
+ * exact next step.
5
+ *
6
+ * This category exists because a capability reachable only by typing a slash
7
+ * command is not, as far as someone clicking through the workspace is
8
+ * concerned, wired up at all. `/mail status` and `/calendar status` answer the
9
+ * same question; a person opening `/settings` to see what is connected should
10
+ * not have to already know those commands exist.
11
+ *
12
+ * ## Why the rows arrive asynchronously
13
+ *
14
+ * Every other settings category reads a value out of config and renders it.
15
+ * This one cannot: "is mail connected?" is only answerable by asking the
16
+ * daemon's handler, which is real I/O. The workspace renders synchronously, so
17
+ * the rows start in `checking` — which is true — and the refresh replaces them
18
+ * and asks for a re-render, the same contract the Services surface uses for its
19
+ * inspections. Nothing is ever shown as "not configured" merely because an
20
+ * answer has not arrived yet; that would be a guess rendered as a fact.
21
+ *
22
+ * No credential is read, held, or rendered here. The probe asks each surface
23
+ * for one item and keeps only whether it failed and why.
24
+ */
25
+
26
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
27
+ import {
28
+ connectionSurfaceLabel,
29
+ probeConnection,
30
+ type ConnectionStatus,
31
+ type ConnectionSurface,
32
+ } from './commands/connection-status.ts';
33
+
34
+ /** The surfaces this category covers, in display order. */
35
+ export const CONNECTION_SURFACES: readonly ConnectionSurface[] = ['mail', 'calendar'];
36
+
37
+ /**
38
+ * The pre-probe rows.
39
+ *
40
+ * Stated as `checking` rather than blank so the category is never empty and
41
+ * never asserts a state it has not established.
42
+ */
43
+ export function initialConnectionEntries(): ConnectionStatus[] {
44
+ return CONNECTION_SURFACES.map((surface) => ({
45
+ surface,
46
+ state: 'checking' as const,
47
+ detail: `Asking the daemon whether ${connectionSurfaceLabel(surface).toLowerCase()} is reachable…`,
48
+ nextActions: [],
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Probe every surface. Returns rows in `CONNECTION_SURFACES` order so the
54
+ * category never reorders itself under the cursor between refreshes.
55
+ */
56
+ export async function buildConnectionEntries(
57
+ gateway: GatewayMethodCatalog | undefined,
58
+ ): Promise<ConnectionStatus[]> {
59
+ return Promise.all(CONNECTION_SURFACES.map((surface) => probeConnection(gateway, surface)));
60
+ }
61
+
62
+ /**
63
+ * The mutable slice of the settings workspace this module drives. Declared
64
+ * structurally rather than importing the class, so the split stays a file-size
65
+ * split and does not become an import cycle.
66
+ */
67
+ export interface ConnectionsHost {
68
+ active: boolean;
69
+ selectedIndex: number;
70
+ connectionEntries: ConnectionStatus[];
71
+ connectionsRefreshing: boolean;
72
+ gatewayMethods: GatewayMethodCatalog | null;
73
+ requestRender: (() => void) | null;
74
+ }
75
+
76
+ /**
77
+ * Refresh the rows from the daemon and repaint.
78
+ *
79
+ * A second call while one is in flight is dropped: entering the tab repeatedly
80
+ * should not queue a probe per keystroke. A probe that lands after the
81
+ * workspace closed is discarded rather than written, so reopening starts from
82
+ * `checking` instead of resurrecting an answer about a finished session.
83
+ */
84
+ export async function refreshConnectionEntries(host: ConnectionsHost): Promise<void> {
85
+ if (host.connectionsRefreshing) return;
86
+ host.connectionsRefreshing = true;
87
+ try {
88
+ const entries = await buildConnectionEntries(host.gatewayMethods ?? undefined);
89
+ if (!host.active) return;
90
+ host.connectionEntries = entries;
91
+ host.requestRender?.();
92
+ } finally {
93
+ host.connectionsRefreshing = false;
94
+ }
95
+ }
96
+
97
+ /** The selected row, clamped, or null when the category is empty. */
98
+ export function selectedConnectionEntry(host: ConnectionsHost): ConnectionStatus | null {
99
+ const entries = host.connectionEntries;
100
+ if (entries.length === 0) return null;
101
+ return entries[Math.max(0, Math.min(entries.length - 1, host.selectedIndex))] ?? null;
102
+ }
@@ -55,6 +55,11 @@ import { enrichRelaySettingDescriptions } from './relay-settings-descriptions.ts
55
55
  import { enrichDaemonOwnedSettingDescriptions } from './daemon-owned-settings-descriptions.ts';
56
56
  import { applyFeatureUnitLayout } from './feature-unit-layout.ts';
57
57
  import { FEATURE_SETTINGS_BY_ID } from '../runtime/feature-settings.ts';
58
+ import {
59
+ buildPaymentsSyntheticEntries,
60
+ isPaymentsSyntheticConfigKey,
61
+ refreshPaymentsSyntheticEntry,
62
+ } from './payments-config.ts';
58
63
 
59
64
  // ---------------------------------------------------------------------------
60
65
  // deepEqual — structural equality for isDefault comparisons
@@ -249,6 +254,19 @@ export function buildSettingGroups(
249
254
  injectSandboxExecSyntheticEntries(groups, configManager);
250
255
  injectExecEnvScrubSyntheticEntry(groups, configManager);
251
256
 
257
+ // Payment card capability (see payments-config.ts): the CONFIG_SCHEMA loop
258
+ // above already populated 'payments' with the fourteen real SDK settings
259
+ // (enabled, defaultCardId, currency, cvvHandling, budgets, shipping,
260
+ // windows, notifyChannels). What's injected here are the four secret-tier
261
+ // card-material fields and the two ordinary address fields, which are
262
+ // TUI-local synthetic sub-keys under that real section (not yet SDK
263
+ // CONFIG_SCHEMA entries — see payments-config.ts's header comment), backed
264
+ // by the same real configManager as everything else in this group.
265
+ const paymentsEntries = groups.get('payments');
266
+ if (paymentsEntries && !paymentsEntries.some((e) => isPaymentsSyntheticConfigKey(e.setting.key))) {
267
+ paymentsEntries.push(...buildPaymentsSyntheticEntries(configManager));
268
+ }
269
+
252
270
  // relay.* is a real SDK CONFIG_SCHEMA domain; append the threat-model note
253
271
  // the SDK's own descriptions don't carry (see relay-settings-descriptions.ts).
254
272
  enrichRelaySettingDescriptions(groups);
@@ -656,6 +674,14 @@ export function refreshEntryValues(
656
674
  entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
657
675
  continue;
658
676
  }
677
+ // payments.* synthetic sub-keys (card material + addresses — see
678
+ // payments-config.ts): real configManager-backed now, but still
679
+ // defensively normalized (undefined -> '') the same way the initial
680
+ // build does, so isDefault stays consistent between build and refresh.
681
+ if (isPaymentsSyntheticConfigKey(entry.setting.key)) {
682
+ refreshPaymentsSyntheticEntry(entry, configManager);
683
+ continue;
684
+ }
659
685
  const raw = configManager.get(entry.setting.key as ConfigKey);
660
686
  // Synthetic entries (e.g. tts.speed) that have no SDK schema key return
661
687
  // undefined from configManager. Normalize using the same logic used at
@@ -694,6 +720,10 @@ export function updateEntryForKey(
694
720
  entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
695
721
  continue;
696
722
  }
723
+ if (isPaymentsSyntheticConfigKey(key)) {
724
+ refreshPaymentsSyntheticEntry(entry, configManager);
725
+ continue;
726
+ }
697
727
  if (isSandboxExecListConfigKey(key)) {
698
728
  entry.currentValue = readSandboxExecList(configManager, key);
699
729
  entry.isDefault = (entry.currentValue as string[]).length === 0;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
17
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
- import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
18
+ import { buildGoodVibesSecretKey, defaultSecretBackedScope, isSecretConfigKey } from '../config/secret-config.ts';
19
19
  import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
20
20
  import type { SettingsSecretsManager } from './settings-modal-secrets.ts';
21
21
 
@@ -41,7 +41,11 @@ export function resetSelected({
41
41
  const key = selected.setting.key as ConfigKey;
42
42
  setValue(key, selected.setting.default);
43
43
  if (isSecretConfigKey(key) && secretsManager) {
44
- void secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
44
+ // Delete from the tier the write went to. A daemon-owned key was stored in
45
+ // the daemon tier (defaultSecretBackedScope), so a delete narrowed to 'user'
46
+ // would report a cleared setting while the daemon kept using the live
47
+ // credential — the reset that isn't one.
48
+ void secretsManager.delete(buildGoodVibesSecretKey(key), { scope: defaultSecretBackedScope(key) }).catch((error) => {
45
49
  logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
46
50
  });
47
51
  }
@@ -5,6 +5,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
5
  import type { SecretsManager } from '../config/secrets.ts';
6
6
  import {
7
7
  buildSecretBackedConfigUpdate,
8
+ defaultSecretBackedScope,
8
9
  getSecretWriteMedium,
9
10
  } from '../config/secret-config.ts';
10
11
 
@@ -24,16 +25,22 @@ export function setSecretBackedSettingValue(args: {
24
25
  }
25
26
 
26
27
  const update = buildSecretBackedConfigUpdate(key, value);
28
+ // A daemon-owned key (surfaces.*, payments.*, controlPlane.*, ...) names a
29
+ // credential the daemon itself executes with, so its secret material lands in
30
+ // the daemon tier — the one the daemon reads with every surface closed —
31
+ // regardless of which client edited it. Everything else stays at user scope.
32
+ // See secret-config.ts's defaultSecretBackedScope.
33
+ const scope = defaultSecretBackedScope(key);
27
34
  if (update.secretKey && update.secretValue !== undefined) {
28
35
  void secretsManager.set(update.secretKey, update.secretValue, {
29
- scope: 'user',
36
+ scope,
30
37
  medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
31
38
  }).catch((error) => {
32
39
  logger.error('SettingsModal: failed to store secret config value', { key, error: summarizeError(error) });
33
40
  });
34
41
  }
35
42
  if (update.clearSecretKey) {
36
- void secretsManager.delete(update.clearSecretKey, { scope: 'user' }).catch((error) => {
43
+ void secretsManager.delete(update.clearSecretKey, { scope }).catch((error) => {
37
44
  logger.error('SettingsModal: failed to clear secret config value', { key, error: summarizeError(error) });
38
45
  });
39
46
  }
@@ -45,12 +45,24 @@ export type SettingsCategory =
45
45
  | 'agents'
46
46
  | 'security'
47
47
  | 'integrations'
48
+ | 'connections'
48
49
  | 'policy'
49
50
  | 'notifications'
50
51
  | 'release'
51
52
  | 'update'
52
53
  | 'power'
53
54
  | 'memory'
55
+ // payments.* — the card on file for daemon-initiated purchases. Like
56
+ // 'profile' below, this entry and its SETTINGS_CATEGORY_GROUPS membership are
57
+ // both mandatory or the whole prefix is dropped from the workspace.
58
+ | 'payments'
59
+ // profile.* — the owner profile (docs/owner-profile.md §12.1). This entry and
60
+ // its SETTINGS_CATEGORY_GROUPS membership below are both mandatory:
61
+ // buildSettingGroups guards every push with `if (groups.has(cat))`, so a
62
+ // config prefix with no category here is dropped from the workspace entirely
63
+ // and reachable only by hand-editing a settings file — see the push.* and
64
+ // cluster.* comments in settings-modal-data.ts for the two times that happened.
65
+ | 'profile'
54
66
  | 'danger';
55
67
 
56
68
  export type SettingsFocusPane = 'categories' | 'settings';
@@ -62,9 +74,13 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
62
74
  { label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
63
75
  { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
64
76
  { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
65
- { label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'mcp', 'cloudflare'] },
66
- { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
67
- { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
77
+ { label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'connections', 'mcp', 'cloudflare'] },
78
+ { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc', 'payments'] },
79
+ // 'profile' sits beside 'memory' and 'learning': all three are what the
80
+ // platform retains about the person using it, and this is the group a reader
81
+ // looking for "what does it know about me" already scans — Interface is
82
+ // presentation and permission posture, and nothing there is a store of facts.
83
+ { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'profile', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
68
84
  { label: 'Advanced', categories: ['release', 'update', 'danger'] },
69
85
  ];
70
86