@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.24.2",
3
+ "version": "1.26.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -43,6 +43,7 @@
43
43
  "build:all-shell": "bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:macos-x64 && bun run build:macos-arm64 && bun run build:windows",
44
44
  "typecheck": "bunx tsc --noEmit",
45
45
  "typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
46
+ "typecheck:coverage": "bun run scripts/typecheck-coverage-rule.ts",
46
47
  "test": "bun run scripts/run-tests.ts",
47
48
  "test:coverage": "bun run scripts/coverage-gate.ts",
48
49
  "version": "bun run scripts/prebuild.ts",
@@ -106,8 +107,8 @@
106
107
  "@anthropic-ai/vertex-sdk": "^0.16.0",
107
108
  "@ast-grep/napi": "^0.42.0",
108
109
  "@aws/bedrock-token-generator": "^1.1.0",
109
- "@pellux/goodvibes-sdk": "1.17.2",
110
- "@pellux/goodvibes-terminal-shell": "1.17.2",
110
+ "@pellux/goodvibes-sdk": "1.19.2",
111
+ "@pellux/goodvibes-terminal-shell": "1.19.2",
111
112
  "bash-language-server": "^5.6.0",
112
113
  "fuse.js": "^7.1.0",
113
114
  "graphql": "^16.13.2",
@@ -133,10 +134,10 @@
133
134
  "typescript": "^5.9.3"
134
135
  },
135
136
  "optionalDependencies": {
136
- "@pellux/goodvibes-tui-linux-x64": "1.24.2",
137
- "@pellux/goodvibes-tui-linux-arm64": "1.24.2",
138
- "@pellux/goodvibes-tui-darwin-x64": "1.24.2",
139
- "@pellux/goodvibes-tui-darwin-arm64": "1.24.2"
137
+ "@pellux/goodvibes-tui-linux-x64": "1.26.0",
138
+ "@pellux/goodvibes-tui-linux-arm64": "1.26.0",
139
+ "@pellux/goodvibes-tui-darwin-x64": "1.26.0",
140
+ "@pellux/goodvibes-tui-darwin-arm64": "1.26.0"
140
141
  },
141
142
  "trustedDependencies": [],
142
143
  "overrides": {
package/src/cli/help.ts CHANGED
@@ -341,6 +341,16 @@ export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
341
341
  ' service-status Show whether the daemon service is installed / enabled / active',
342
342
  ' migrate-service Guided migration from an install-script goodvibes-daemon.service unit; prints a plan',
343
343
  ' unless run with -y/--yes (never auto-migrates)',
344
+ ' send [message] Send a message to one of your configured channels — Telegram, ntfy,',
345
+ ' Discord, Slack, Google Chat, Signal, WhatsApp, iMessage, Teams,',
346
+ ' BlueBubbles, Mattermost, Matrix or a webhook. Takes the message as',
347
+ ' an argument or on stdin, so it composes with other tooling.',
348
+ ' --channel <id> picks the channel; with none named it uses your one',
349
+ ' configured channel and says which. --to <address> targets a specific',
350
+ ' topic / chat / room within it, --title <text> sets a title, and',
351
+ ' --list shows every channel with where it would send. A channel that',
352
+ ' is off is refused rather than redirected to the default, and a',
353
+ ' failed send exits non-zero with the provider\'s own error.',
344
354
  ' cluster <command> Share inbound channel work with your other machines on this network.',
345
355
  ' status | create | join | key | nodes | forget <machine> | rotate [--now]',
346
356
  ' | leave | rename | groups',
@@ -1,6 +1,56 @@
1
1
  export const REDACTED_VALUE = '<redacted>';
2
2
 
3
+ // Matches a config path whose LAST SEGMENT is exactly one of these words. That
4
+ // is the whole reach of this pattern, and it is why the declared list below
5
+ // exists: `surfaces.calendar.caldavPassword` does not end in a `.password`
6
+ // segment, it ends in a `caldavPassword` segment, so this pattern answers false
7
+ // for it — as it does for `imapPassword`, `appPassword` and `authToken`.
3
8
  const SENSITIVE_PATH_PATTERN = /(^|\.)(apiKey|accessToken|botToken|appToken|signingSecret|webhookSecret|verifyToken|verificationToken|secret|password|token|keyFile)$/i;
9
+
10
+ // Credential-bearing config keys that are sensitive by NAME rather than by the
11
+ // generic trailing-word list above. Every one of these was carried in the clear
12
+ // by a support bundle before it was listed here, because the pattern matches a
13
+ // trailing WORD and these names carry the credential word in the middle or
14
+ // prefixed by a protocol ("caldavPassword", "imapPassword", "appPassword",
15
+ // "authToken").
16
+ //
17
+ // The `*Ref` Cloudflare keys normally hold a `goodvibes://secrets/...`
18
+ // reference, which shouldRedactValue already lets through as safe. They are
19
+ // listed as a BACKSTOP, for the same reason the mail keys are worth listing
20
+ // twice over: it costs nothing, and it is what stands between a future code
21
+ // path that writes a literal token there and that token reaching a file the
22
+ // owner emails to someone for support.
23
+ const SENSITIVE_CONFIG_KEYS: ReadonlySet<string> = new Set([
24
+ // Mailbox / CalDAV — the credentials the daemon polls mail and calendar with.
25
+ 'surfaces.email.password',
26
+ 'surfaces.email.imapPassword',
27
+ 'surfaces.email.imap.password',
28
+ 'surfaces.email.smtp.password',
29
+ 'surfaces.calendar.caldavPassword',
30
+ // Telephony.
31
+ 'surfaces.telephony.authToken',
32
+ 'surfaces.msteams.appPassword',
33
+ // Cloudflare token references.
34
+ 'cloudflare.apiTokenRef',
35
+ 'cloudflare.tunnelTokenRef',
36
+ 'cloudflare.accessServiceTokenRef',
37
+ 'cloudflare.workerTokenRef',
38
+ 'cloudflare.workerClientTokenRef',
39
+ // Cluster shared phrase.
40
+ 'cluster.secret',
41
+ // Payment card material. ADDED to this set, never substituted for it: this
42
+ // set is a hardcoded list with no delegation, so replacing it wholesale with
43
+ // another product's would silently stop redacting whatever that one omits.
44
+ // The four names below end in no word the suffix pattern knows —
45
+ // "cardNumber", "cardExpiry" and "cardholderName" match none of them — which
46
+ // is the whole reason a declared list exists rather than a naming habit.
47
+ // (The `.map` below lowercases every entry; the lookup lowercases too.)
48
+ 'payments.cardNumber',
49
+ 'payments.cardExpiry',
50
+ 'payments.cardCvv',
51
+ 'payments.cardholderName',
52
+ ].map((key) => key.toLowerCase()));
53
+
4
54
  const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
5
55
  /\bsk-[A-Za-z0-9_-]{16,}\b/g,
6
56
  /\bghp_[A-Za-z0-9_]{16,}\b/g,
@@ -13,7 +63,7 @@ const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
13
63
  ];
14
64
 
15
65
  export function isSensitiveConfigPath(path: string): boolean {
16
- return SENSITIVE_PATH_PATTERN.test(path);
66
+ return SENSITIVE_PATH_PATTERN.test(path) || SENSITIVE_CONFIG_KEYS.has(path.toLowerCase());
17
67
  }
18
68
 
19
69
  export function isRedactedValue(value: unknown): boolean {
@@ -25,9 +75,14 @@ export interface RedactedConfigResult<T> {
25
75
  readonly redactedPaths: readonly string[];
26
76
  }
27
77
 
78
+ // Redaction rule for sensitive config paths:
79
+ // - Non-string values: redact if truthy (i.e. non-null, non-undefined, non-zero, non-false).
80
+ // Rationale: zero and false are never meaningful secrets; null/undefined mean absent.
81
+ // - String values: redact non-empty strings that are not goodvibes:// secret refs.
82
+ // Rationale: empty string means unset; secret refs are safe placeholders, not raw values.
28
83
  function shouldRedactValue(path: string, value: unknown): boolean {
29
84
  if (!isSensitiveConfigPath(path)) return false;
30
- if (typeof value !== 'string') return value !== undefined && value !== null;
85
+ if (typeof value !== 'string') return value !== null && value !== undefined && Boolean(value);
31
86
  if (value.trim().length === 0) return false;
32
87
  if (value.startsWith('goodvibes://secrets/')) return false;
33
88
  return true;
@@ -64,7 +119,17 @@ export function redactConfig<T>(config: T): RedactedConfigResult<T> {
64
119
  }
65
120
 
66
121
  export function redactText(input: string): string {
67
- let output = input;
122
+ // Assignment form: keyword=value — anchored so 'monkey=' and 'donkey=' do NOT match.
123
+ // Matches: token=, access_token=, api_key=, api-key=, secret=, password= and colon form token: value
124
+ let output = input
125
+ .replace(
126
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*=\s*([^ \t\r\n"'`]+)/gi,
127
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
128
+ )
129
+ .replace(
130
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*:\s*([^ \t\r\n"'`]+)/gi,
131
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
132
+ );
68
133
  for (const pattern of SECRET_LIKE_TEXT_PATTERNS) {
69
134
  output = output.replace(pattern, REDACTED_VALUE);
70
135
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * goodvibes-home.ts — where this process's GoodVibes tree lives, resolved in
3
+ * ONE place for every entry point.
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * `GOODVIBES_HOME` relocates the tree root. It was read by exactly one file —
8
+ * `src/daemon/cli.ts` — and the CLIENT entry point (`src/main.ts`) called
9
+ * `homedir()` unconditionally, on the same object literal where it did honour
10
+ * `GOODVIBES_WORKING_DIR`. So a harness that set `GOODVIBES_HOME` to a
11
+ * throwaway directory and then ran a client command got a process that read and
12
+ * wrote the REAL tree: settings, workspace state, and — because the daemon-tier
13
+ * secret store is derived from the tree root — `~/.goodvibes/daemon/secrets.enc`.
14
+ *
15
+ * That is not hypothetical. An isolated round stored two throwaway credentials
16
+ * and they landed in the owner's live secret store, where they had to be
17
+ * removed by hand. An isolation boundary a process can walk out of is not a
18
+ * boundary; the honest fix is that no entry point resolves a home of its own.
19
+ *
20
+ * ## What each variable means
21
+ *
22
+ * - `GOODVIBES_HOME` — the tree ROOT, the directory `.goodvibes/` sits under.
23
+ * Setting it relocates settings, workspace, discovery roots, and every tier
24
+ * of the secret store. This is what an isolated harness or a service unit
25
+ * sets.
26
+ * - `GOODVIBES_DAEMON_HOME` — the daemon's own IDENTITY directory (auth users,
27
+ * operator tokens, daemon settings). It falls under the tree root unless set
28
+ * separately, and it names only that directory: it is not a second way to
29
+ * move the tree. Both entry points resolve it the same way now, so a client
30
+ * given only this variable no longer computes the daemon-tier secret path
31
+ * from the real home while everything else points at a sandbox.
32
+ *
33
+ * Both readers take `env` as an argument rather than reaching for
34
+ * `process.env`, so the resolution is exercisable without a test having to
35
+ * mutate the environment of the process running it.
36
+ */
37
+
38
+ import { homedir } from 'node:os';
39
+ import { isAbsolute, join, resolve } from 'node:path';
40
+
41
+ /** The two roots every surface derives its storage from. */
42
+ export interface GoodVibesHomeOwnership {
43
+ /** The tree root: the directory `.goodvibes/` sits under. */
44
+ readonly homeDirectory: string;
45
+ /** The daemon's own identity directory, under the tree root by default. */
46
+ readonly daemonHomeDirectory: string;
47
+ /**
48
+ * True when either `GOODVIBES_HOME` or `GOODVIBES_DAEMON_HOME` named an
49
+ * override (a blank/whitespace-only value does not count — see
50
+ * resolveGoodVibesHome). Callers that need to know "is this process running
51
+ * out of a relocated tree or identity directory" (e.g. to refuse adopting
52
+ * the machine's real service unit) read this instead of re-reading the raw
53
+ * environment variable themselves — this module is the one reader.
54
+ */
55
+ readonly isOverridden: boolean;
56
+ }
57
+
58
+ /**
59
+ * The tree root for this process.
60
+ *
61
+ * A blank or whitespace-only value is treated as absent rather than as the
62
+ * empty path, because `GOODVIBES_HOME=` in a unit file or a shell wrapper means
63
+ * "unset", and resolving it to `''` would put the whole tree at the filesystem
64
+ * root.
65
+ */
66
+ export function resolveGoodVibesHome(env: NodeJS.ProcessEnv = process.env): string {
67
+ const override = env['GOODVIBES_HOME']?.trim();
68
+ if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override);
69
+ // The login home comes from the injected env first so the whole resolution is
70
+ // exercisable without a test mutating the environment it is running in. This
71
+ // is not a behaviour change: on Linux and macOS `homedir()` already prefers
72
+ // $HOME, and where it does not (Windows, where HOME is usually unset) the
73
+ // fallback below is what answers.
74
+ const loginHome = env['HOME']?.trim();
75
+ return loginHome ? loginHome : homedir();
76
+ }
77
+
78
+ /**
79
+ * The daemon's identity directory.
80
+ *
81
+ * @param homeDirectory - The already-resolved tree root, so a caller cannot
82
+ * accidentally combine an overridden root with a default daemon home.
83
+ */
84
+ export function resolveGoodVibesDaemonHome(
85
+ homeDirectory: string,
86
+ env: NodeJS.ProcessEnv = process.env,
87
+ ): string {
88
+ const override = env['GOODVIBES_DAEMON_HOME']?.trim();
89
+ if (!override) return join(homeDirectory, '.goodvibes', 'daemon');
90
+ return isAbsolute(override) ? override : resolve(process.cwd(), override);
91
+ }
92
+
93
+ /**
94
+ * The `.goodvibes` directory itself — the tree, not the root it sits under.
95
+ *
96
+ * This exists because the two were being derived independently and did not
97
+ * agree. `GOODVIBES_HOME` meant the tree ROOT to the runtime
98
+ * (`<GOODVIBES_HOME>/.goodvibes/...`) and meant the `.goodvibes` DIRECTORY to
99
+ * the two reporting scripts, which read it as
100
+ * `process.env.GOODVIBES_HOME ?? join(homedir(), '.goodvibes')`. Set it to
101
+ * `/tmp/sandbox` to isolate a run and the runtime wrote
102
+ * `/tmp/sandbox/.goodvibes/`, while a script inspected `/tmp/sandbox` as though
103
+ * it were already the tree — so the two halves of one round disagreed about
104
+ * which files were even in scope.
105
+ *
106
+ * That is the shape of an incident that has already happened here: a
107
+ * home-redirection variable honoured by one entry point and ignored by another
108
+ * put two throwaway credentials in the owner's live secret store. One variable
109
+ * has one meaning now, and it is the runtime's, because the runtime is what
110
+ * writes secrets and because an isolation harness needs a single root that
111
+ * everything falls under.
112
+ *
113
+ * A caller that genuinely wants to name a `.goodvibes` directory directly —
114
+ * auditing a tree that is not under any home — passes it explicitly rather than
115
+ * through the environment. Both scripts keep a `--home` flag for exactly that.
116
+ */
117
+ export function resolveGoodVibesTreeDirectory(env: NodeJS.ProcessEnv = process.env): string {
118
+ return join(resolveGoodVibesHome(env), '.goodvibes');
119
+ }
120
+
121
+ /** Both roots at once — what an entry point wants. */
122
+ export function resolveGoodVibesHomeOwnership(
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): GoodVibesHomeOwnership {
125
+ const homeDirectory = resolveGoodVibesHome(env);
126
+ return {
127
+ homeDirectory,
128
+ daemonHomeDirectory: resolveGoodVibesDaemonHome(homeDirectory, env),
129
+ isOverridden: Boolean(env['GOODVIBES_HOME']?.trim()) || Boolean(env['GOODVIBES_DAEMON_HOME']?.trim()),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * True when either root was relocated for this process.
135
+ *
136
+ * The daemon lifecycle asks this to decide whether it may adopt the machine's
137
+ * service unit: a process told to run out of somewhere else is a throwaway, and
138
+ * one that adopted the unit had systemd supervise it as the machine's daemon
139
+ * for five hours while it read the real home's credentials.
140
+ *
141
+ * It lives here because the two variables have exactly one reader, and the
142
+ * answer is a question ABOUT them. A caller checking `process.env` for itself is
143
+ * how the second meaning that started all of this gets reintroduced — so the
144
+ * question is answered here rather than asked again somewhere else.
145
+ */
146
+ export function hasOverriddenGoodVibesHome(env: NodeJS.ProcessEnv = process.env): boolean {
147
+ return (env['GOODVIBES_HOME']?.trim() ?? '') !== ''
148
+ || (env['GOODVIBES_DAEMON_HOME']?.trim() ?? '') !== '';
149
+ }
@@ -1,8 +1,27 @@
1
- import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
1
+ import { isSecretRefInput, isDaemonOwnedConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { ConfigKey } from './index.ts';
3
3
  import type { SecretScope, SecretStorageMedium } from './secrets.ts';
4
4
 
5
5
  export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
6
+ // Mailbox and CalDAV credentials. Their own CONFIG_SCHEMA descriptions read
7
+ // "Stored in the daemon secret tier, never in config" — and until they were
8
+ // listed here that sentence was aspirational: the settings modal wrote them
9
+ // as plain strings into a config JSON file, because membership in this set is
10
+ // the thing that routes an edit through the secret manager instead.
11
+ //
12
+ // The daemon reads each of these back with
13
+ // resolveConfigSecret('<key>') → GOODVIBES_<KEY>, which is exactly the store
14
+ // key buildGoodVibesSecretKey() writes — see
15
+ // daemon/handlers/inbox/providers/email.ts.
16
+ 'surfaces.email.password',
17
+ 'surfaces.email.imapPassword',
18
+ 'surfaces.email.imap.password',
19
+ 'surfaces.email.smtp.password',
20
+ 'surfaces.calendar.caldavPassword',
21
+ // Telephony delivery credentials — same shape, same file, same gap.
22
+ 'surfaces.telephony.authToken',
23
+ 'surfaces.telephony.token',
24
+ 'surfaces.telephony.webhookSecret',
6
25
  'surfaces.slack.signingSecret',
7
26
  'surfaces.slack.botToken',
8
27
  'surfaces.slack.appToken',
@@ -23,6 +42,16 @@ export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
23
42
  'surfaces.bluebubbles.password',
24
43
  'surfaces.mattermost.botToken',
25
44
  'surfaces.matrix.accessToken',
45
+ // TUI-local synthetic sub-keys, one level under the SDK's real `payments`
46
+ // section (not yet a scalar CONFIG_SCHEMA entry — same situation as
47
+ // tts.speed, behavior.notifyAfterSeconds, etc. in settings-modal-data.ts),
48
+ // hence the cast. See input/payments-config.ts for why these are named flat
49
+ // (payments.cardNumber, not payments.card.number): a flat one-level leaf is
50
+ // the shape the real ConfigManager tolerates for an undeclared key.
51
+ 'payments.cardNumber' as ConfigKey,
52
+ 'payments.cardExpiry' as ConfigKey,
53
+ 'payments.cardCvv' as ConfigKey,
54
+ 'payments.cardholderName' as ConfigKey,
26
55
  ]);
27
56
 
28
57
  export interface SecretBackedConfigUpdate {
@@ -96,6 +125,28 @@ export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: st
96
125
  };
97
126
  }
98
127
 
128
+ /**
129
+ * Where a secret-backed write lands when the caller did not name a scope.
130
+ *
131
+ * A daemon-owned config key (`surfaces.*`, `payments.*`, `controlPlane.*`, ...)
132
+ * names a credential the DAEMON executes with, not this interactive client, so
133
+ * its secret material belongs in the daemon-scoped tier the daemon actually
134
+ * reads — the same rule the SDK's config-ownership.ts already applies to the
135
+ * `goodvibes://` reference that points at it.
136
+ *
137
+ * Defaulting these to 'user' (the historical behavior here) split the pair: the
138
+ * reference landed in the daemon's own settings file, because ConfigManager
139
+ * routes daemon-owned keys there, while the value it pointed at sat in a tier
140
+ * the daemon never resolves. The surface reported success and the daemon found
141
+ * nothing. For the mailbox password that is the whole feature failing silently —
142
+ * the daemon is the process that polls IMAP and answers over Telegram, and it
143
+ * does so with every surface closed. A payment card entered through
144
+ * /payments card is the same shape of failure at purchase time.
145
+ */
146
+ export function defaultSecretBackedScope(configKey: ConfigKey): SecretScope {
147
+ return isDaemonOwnedConfigKey(configKey) ? 'daemon' : 'user';
148
+ }
149
+
99
150
  export async function persistSecretBackedConfigValue(
100
151
  configManager: SecretBackedConfigManager,
101
152
  secretsManager: SecretBackedSecretStore | null | undefined,
@@ -104,16 +155,21 @@ export async function persistSecretBackedConfigValue(
104
155
  options: { readonly scope?: SecretScope } = {},
105
156
  ): Promise<string> {
106
157
  const update = buildSecretBackedConfigUpdate(configKey, rawValue);
107
- const scope = options.scope ?? 'user';
158
+ const scope = options.scope ?? defaultSecretBackedScope(configKey);
159
+ const medium = getSecretWriteMedium(configManager.get('storage.secretPolicy'));
160
+
161
+ // 1. Validate config write first. If setDynamic throws, no secret is written (avoids orphans).
162
+ configManager.setDynamic(configKey, update.configValue);
163
+
164
+ // 2. Write new secret only after config accepted it.
108
165
  if (update.secretKey && update.secretValue !== undefined && secretsManager) {
109
- await secretsManager.set(update.secretKey, update.secretValue, {
110
- scope,
111
- medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
112
- });
166
+ await secretsManager.set(update.secretKey, update.secretValue, { scope, medium });
113
167
  }
168
+
169
+ // 3. Clear old secret — pass the same medium so plaintext-medium secrets are found for deletion.
114
170
  if (update.clearSecretKey && secretsManager?.delete) {
115
- await secretsManager.delete(update.clearSecretKey, { scope });
171
+ await secretsManager.delete(update.clearSecretKey, { scope, medium });
116
172
  }
117
- configManager.setDynamic(configKey, update.configValue);
173
+
118
174
  return update.configValue;
119
175
  }
@@ -1,7 +1,7 @@
1
1
  import type { UiRuntimeEvents, RuntimeEventBus } from '@/runtime/index.ts';
2
2
  import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
3
  import { buildCompactionReceiptBlock } from './compaction-receipt.ts';
4
- import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
4
+ import { workstreamFailureNotification } from './workstream-notification.ts';
5
5
  import { persistTurnAnchors, recordTurnAnchor, summarizeTurnLabel } from './rewind-turn-anchors.ts';
6
6
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
7
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -328,21 +328,12 @@ export function wireTurnEventHandlers(
328
328
  unsubs.push(events.workflows.on('WORKFLOW_CHAIN_FAILED', (payload) => {
329
329
  if (!shouldFireAlert(focusTracker, configGet, 'behavior.notifyOnChainFailure')) return;
330
330
  try {
331
- // An operator cancellation is an intended stop, not a failure — narrate it
332
- // as cancelled (the reason already carries the landed-work count from the
333
- // chain's edit ledger), so the notification never contradicts the cancelled
334
- // chain/owner/cohort surfaces.
335
- if (payload.failureKind === 'cancelled') {
336
- notifyCompletion('GoodVibes — WRFC chain cancelled', `chain ${payload.chainId.slice(0, 12)} cancelled: ${payload.reason}`, FORCE_NOTIFY_DURATION_MS);
337
- } else if (payload.failureKind === 'max_turns') {
338
- // A turn-budget exhaustion is a spent ceiling, not an infrastructure
339
- // failure — narrate the limit and where it came from, from the typed
340
- // event fields, never a regex of the prose reason.
341
- notifyCompletion('GoodVibes — WRFC chain hit its turn budget', `chain ${payload.chainId.slice(0, 12)} ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`, FORCE_NOTIFY_DURATION_MS);
342
- } else {
343
- const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
344
- notifyCompletion('GoodVibes — WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
345
- }
331
+ // Title and body come from workstream-notification.ts, which is where
332
+ // the three branches (cancelled / turn budget / failed) are narrated and
333
+ // tested. A notification is a message to a person, so it carries neither
334
+ // the internal name for the machinery nor the chain id.
335
+ const notice = workstreamFailureNotification(payload);
336
+ notifyCompletion(notice.title, notice.body, FORCE_NOTIFY_DURATION_MS);
346
337
  } catch (err) {
347
338
  logger.debug('turn-event-wiring: chain-failure notify error', { error: String(err) });
348
339
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * workstream-notification.ts — the desktop notification a finished workstream
3
+ * pushes, in words rather than identifiers.
4
+ *
5
+ * These three notifications used to read:
6
+ *
7
+ * GoodVibes — WRFC chain failed
8
+ * chain 7f3a91c02b4e failed: review rejected
9
+ *
10
+ * A desktop notification is a message TO a person, so the standing rule applies
11
+ * to it the same way it applies to a chat message: no internal name for the
12
+ * machinery, and no register id. `WRFC` is the first; `7f3a91c02b4e` is the
13
+ * second, and it is not something the reader can do anything with — it is not
14
+ * the commit, not the branch, not the session.
15
+ *
16
+ * What the reader can act on is why the work stopped, so that is what the body
17
+ * now leads with. Two workstreams that end at the same moment are told apart by
18
+ * their reasons, which is the same principle the channel renderer follows: in
19
+ * plain words, never by an opaque identifier.
20
+ *
21
+ * Split out of turn-event-wiring.ts as a pure function so the text is testable
22
+ * on its own — the wiring itself cannot be asserted against without mocking a
23
+ * process-global notifier.
24
+ */
25
+ import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
26
+
27
+ /** The fields of WORKFLOW_CHAIN_FAILED this narration reads. */
28
+ export interface WorkstreamFailureNarrationInput {
29
+ readonly reason: string;
30
+ readonly failureKind?: 'transport' | 'other' | 'cancelled' | 'max_turns' | undefined;
31
+ readonly turnLimit?: number | undefined;
32
+ readonly turnLimitSource?: 'default' | 'spawn-override' | 'policy-bound' | undefined;
33
+ }
34
+
35
+ export interface WorkstreamNotification {
36
+ readonly title: string;
37
+ readonly body: string;
38
+ }
39
+
40
+ /**
41
+ * Title and body for a workstream that reached a terminal state.
42
+ *
43
+ * An operator cancellation is an intended stop, not a failure — it is narrated
44
+ * as cancelled (the reason already carries the landed-work count from the
45
+ * workstream's edit ledger) so the notification never contradicts the cancelled
46
+ * workstream/owner/cohort surfaces. A turn-budget exhaustion is a spent ceiling
47
+ * rather than an infrastructure error, and its limit and source are read from
48
+ * the typed event fields, never from a regex of the prose reason.
49
+ */
50
+ export function workstreamFailureNotification(
51
+ payload: WorkstreamFailureNarrationInput,
52
+ ): WorkstreamNotification {
53
+ if (payload.failureKind === 'cancelled') {
54
+ return {
55
+ title: 'GoodVibes — workstream cancelled',
56
+ body: `Cancelled: ${payload.reason}`,
57
+ };
58
+ }
59
+ if (payload.failureKind === 'max_turns') {
60
+ return {
61
+ title: 'GoodVibes — workstream hit its turn budget',
62
+ body: `The workstream ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`,
63
+ };
64
+ }
65
+ return {
66
+ title: 'GoodVibes — workstream failed',
67
+ body: `Failed: ${payload.failureKind === 'transport' ? 'transient transport error' : payload.reason}`,
68
+ };
69
+ }
package/src/daemon/cli.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { homedir } from 'node:os';
2
- import { isAbsolute, join, resolve } from 'node:path';
3
2
  import {
4
3
  ConfigManager,
5
4
  deriveControlPlaneBaseUrl,
@@ -8,6 +7,7 @@ import {
8
7
  } from '@pellux/goodvibes-sdk/platform/config';
9
8
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
10
9
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
10
+ import { resolveGoodVibesHomeOwnership } from '../config/goodvibes-home.ts';
11
11
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
12
12
  import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
13
13
  import { createRuntimeStore } from '../runtime/store/index.ts';
@@ -34,6 +34,9 @@ import {
34
34
  persistProviders,
35
35
  } from '@pellux/goodvibes-sdk/platform/discovery';
36
36
  import { createSafeHostServeFactory } from './safe-serve.ts';
37
+ import { runSendCommand } from './send/command.ts';
38
+ import { createSendStack } from './send/composition.ts';
39
+ import { readAllStdin } from './send/stdin.ts';
37
40
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
38
41
  import { runClusterCommand } from '../cluster/commands.ts';
39
42
  import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
@@ -59,6 +62,8 @@ type DaemonCliOwnership = {
59
62
  readonly homeDirectory: string;
60
63
  /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
61
64
  readonly daemonHomeDirectory: string;
65
+ /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override — see goodvibes-home.ts. */
66
+ readonly isOverridden: boolean;
62
67
  };
63
68
 
64
69
  // CLI flag parsing delegated to shared module — see src/cli-flags.ts
@@ -83,15 +88,16 @@ type DaemonCliTokens = {
83
88
  * daemon home then falls under it unless separately overridden.
84
89
  */
85
90
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
86
- const homeDirectory = process.env['GOODVIBES_HOME']?.trim() || homedir();
87
- const daemonHomeOverride = process.env['GOODVIBES_DAEMON_HOME']?.trim();
88
- const daemonHomeDirectory = daemonHomeOverride
89
- ? (isAbsolute(daemonHomeOverride) ? daemonHomeOverride : resolve(process.cwd(), daemonHomeOverride))
90
- : join(homeDirectory, '.goodvibes', 'daemon');
91
+ // Both roots come from src/config/goodvibes-home.ts, which the CLIENT entry
92
+ // point also uses. They were resolved independently, and the client's copy
93
+ // simply did not read GOODVIBES_HOME — so a redirected client wrote into the
94
+ // real tree while the daemon honoured the redirect.
95
+ const { homeDirectory, daemonHomeDirectory, isOverridden } = resolveGoodVibesHomeOwnership();
91
96
  return {
92
97
  workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
93
98
  homeDirectory,
94
99
  daemonHomeDirectory,
100
+ isOverridden,
95
101
  };
96
102
  }
97
103
 
@@ -137,6 +143,44 @@ async function main(): Promise<void> {
137
143
  process.exit(result.exitCode);
138
144
  }
139
145
 
146
+ // `send …` is intercepted here for both of the reasons `cluster` is, plus one
147
+ // of its own.
148
+ //
149
+ // Before the parser, because the message is arbitrary operator text: a
150
+ // message beginning with a dash, or one carrying `--port` inside it, must
151
+ // reach the channel rather than be eaten as a daemon flag.
152
+ //
153
+ // Before the runtime, because this composes only the services a delivery
154
+ // needs (see send/composition.ts) and must not start a second copy of the
155
+ // pollers, cluster election and LAN scan a running daemon already owns.
156
+ //
157
+ // And it must work when NO daemon is running, which is much of the point: the
158
+ // reason to message the owner is usually that something stopped.
159
+ if (rawArgs[0] === 'send') {
160
+ const ownership = resolveDaemonCliOwnership();
161
+ runDaemonConfigMigration(ownership.homeDirectory);
162
+ const stack = createSendStack({
163
+ workingDirectory: ownership.workingDirectory,
164
+ homeDirectory: ownership.homeDirectory,
165
+ daemonHomeDirectory: ownership.daemonHomeDirectory,
166
+ });
167
+ const result = await runSendCommand(rawArgs.slice(1), {
168
+ configManager: stack.configManager,
169
+ deliver: stack.deliver,
170
+ readStdin: readAllStdin,
171
+ stdinIsTty: process.stdin.isTTY === true,
172
+ });
173
+ for (const line of result.lines) {
174
+ // eslint-disable-next-line no-console
175
+ if (result.exitCode === 0) console.log(line);
176
+ else console.error(line);
177
+ }
178
+ // The activity log holds the OUTBOUND_HTTP record for the send that just
179
+ // happened (or did not); a process exiting this promptly would drop it.
180
+ flushActivityLogSync();
181
+ process.exit(result.exitCode);
182
+ }
183
+
140
184
  // Parse CLI flags first so --daemon-home and --working-dir env vars are set
141
185
  // before resolveDaemonCliOwnership() reads them.
142
186
  const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
@@ -169,7 +213,7 @@ async function main(): Promise<void> {
169
213
  logger.info('daemon: --working-dir flag applied', { workingDir: cliFlags.workingDir });
170
214
  }
171
215
 
172
- const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory } = resolveDaemonCliOwnership();
216
+ const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
173
217
  runDaemonConfigMigration(homeDirectory);
174
218
  const config = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
175
219
  new GlobalNetworkTransportInstaller().install(config);
@@ -314,6 +358,11 @@ async function main(): Promise<void> {
314
358
  getConversationTitle: () => 'goodvibes daemon',
315
359
  workingDir,
316
360
  homeDirectory,
361
+ // Honour --daemon-home / GOODVIBES_DAEMON_HOME on the CREDENTIAL store, not
362
+ // just the identity directory. Without this a daemon told to run out of a
363
+ // temp tree still read the real home's daemon secrets, so "isolating" a
364
+ // test daemon left it holding the owner's live credentials.
365
+ daemonHomeDirectory,
317
366
  // The standalone daemon observes externally-launched coding-agent sessions
318
367
  // on the host read-only (fleet visibility + steer; never counted, never
319
368
  // stopped). Daemon-side only — the interactive process reads this snapshot
@@ -361,6 +410,12 @@ async function main(): Promise<void> {
361
410
  // undefined for a non-binary install, so a dev run stays host-managed and
362
411
  // never swaps the interpreter. See lifecycle.ts.
363
412
  updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
413
+ // A daemon running out of an overridden home must never adopt the machine's
414
+ // service unit. One did: started from a scratchpad with --daemon-home, it
415
+ // found the unit not running, wrote its own scratchpad ExecStart into
416
+ // ~/.config/systemd/user/goodvibes.service and exited, and systemd then
417
+ // supervised the throwaway as the machine's daemon for five hours.
418
+ hasOverriddenHome,
364
419
  // The SAME coordinator this repository's inbox poller registered with (see
365
420
  // runtime/cluster-composition.ts). The facade must reuse it rather than
366
421
  // compose its own: two coordinators in one process are two nodes in the