@pellux/goodvibes-daemon 1.28.19 → 1.28.21

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 (114) hide show
  1. package/CHANGELOG.md +119 -63
  2. package/README.md +24 -19
  3. package/bin/launcher-support.js +5 -5
  4. package/package.json +6 -4
  5. package/scripts/postinstall.js +8 -8
  6. package/src/cli/command-catalog.ts +22 -22
  7. package/src/cli/completion.ts +4 -4
  8. package/src/cli/help.ts +5 -5
  9. package/src/cli/index.ts +3 -3
  10. package/src/cli/parser.ts +2 -2
  11. package/src/cli/surface-catalog.ts +1 -1
  12. package/src/cli/types.ts +2 -2
  13. package/src/cluster/daemon-ws-call.ts +5 -5
  14. package/src/cluster/raw-reply-route.ts +5 -5
  15. package/src/config/checkpoint-settings.ts +7 -7
  16. package/src/config/config-key-guard.ts +22 -0
  17. package/src/config/run-daemon-config-migration.ts +3 -3
  18. package/src/config/secret-config.ts +7 -7
  19. package/src/config/surface.ts +3 -3
  20. package/src/core/pairing-banner.ts +5 -5
  21. package/src/daemon/cli.ts +45 -43
  22. package/src/daemon/config-command.ts +15 -15
  23. package/src/daemon/handlers/context.ts +1 -1
  24. package/src/daemon/handlers/contracts.ts +19 -4
  25. package/src/daemon/handlers/credentials.ts +1 -1
  26. package/src/daemon/handlers/drafts/draft-store.ts +3 -3
  27. package/src/daemon/handlers/drafts/register.ts +4 -4
  28. package/src/daemon/handlers/inbox/aggregator.ts +8 -8
  29. package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
  30. package/src/daemon/handlers/inbox/index.ts +7 -7
  31. package/src/daemon/handlers/inbox/mapping.ts +2 -2
  32. package/src/daemon/handlers/inbox/poller.ts +5 -5
  33. package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
  34. package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
  35. package/src/daemon/handlers/inbox/providers/email.ts +3 -3
  36. package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
  37. package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
  38. package/src/daemon/handlers/index.ts +18 -8
  39. package/src/daemon/handlers/payments/address-store.ts +54 -0
  40. package/src/daemon/handlers/payments/budget-store.ts +356 -0
  41. package/src/daemon/handlers/payments/card-store.ts +486 -0
  42. package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
  43. package/src/daemon/handlers/payments/index.ts +38 -0
  44. package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
  45. package/src/daemon/handlers/payments/notifier.ts +112 -0
  46. package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
  47. package/src/daemon/handlers/payments/register.ts +518 -0
  48. package/src/daemon/handlers/register.ts +3 -3
  49. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
  51. package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
  52. package/src/daemon/handlers/remote/backends/types.ts +2 -2
  53. package/src/daemon/handlers/remote/dispatcher.ts +3 -3
  54. package/src/daemon/handlers/remote/index.ts +1 -1
  55. package/src/daemon/handlers/remote/peer-registry.ts +62 -13
  56. package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
  57. package/src/daemon/handlers/routing/index.ts +1 -1
  58. package/src/daemon/handlers/routing/route-store.ts +1 -1
  59. package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
  60. package/src/daemon/handlers/sqlite-store.ts +9 -9
  61. package/src/daemon/handlers/triage/index.ts +1 -1
  62. package/src/daemon/handlers/triage/integration.ts +3 -3
  63. package/src/daemon/handlers/triage/pipeline.ts +2 -2
  64. package/src/daemon/handlers/triage/scorer.ts +2 -2
  65. package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
  66. package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
  67. package/src/daemon/handlers/triage/tagger/index.ts +1 -1
  68. package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
  69. package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
  70. package/src/daemon/handlers/triage/types.ts +2 -2
  71. package/src/daemon/lifecycle.ts +5 -5
  72. package/src/daemon/local-daemon-state.ts +7 -7
  73. package/src/daemon/pair-command.ts +14 -14
  74. package/src/daemon/provision-wake-model.ts +5 -5
  75. package/src/daemon/send/channels.ts +7 -7
  76. package/src/daemon/send/command.ts +11 -11
  77. package/src/daemon/send/composition.ts +5 -5
  78. package/src/daemon/send/failure-text.ts +6 -6
  79. package/src/daemon/send/inert-text.ts +18 -18
  80. package/src/daemon/send/stdin.ts +3 -3
  81. package/src/daemon/service-commands.ts +32 -32
  82. package/src/daemon/sessions-command.ts +7 -7
  83. package/src/daemon/status-command.ts +22 -22
  84. package/src/daemon/webui-command.ts +14 -14
  85. package/src/runtime/boot-tasks.ts +1 -1
  86. package/src/runtime/browser-checkout-seam-holder.ts +55 -0
  87. package/src/runtime/cluster-composition.ts +9 -9
  88. package/src/runtime/cluster-group-composition.ts +7 -7
  89. package/src/runtime/conversation-rewind-port.ts +8 -8
  90. package/src/runtime/credential-composition.ts +2 -2
  91. package/src/runtime/daemon-handler-composition.ts +61 -4
  92. package/src/runtime/device-posture-composition.ts +10 -10
  93. package/src/runtime/disposal-wiring.ts +8 -8
  94. package/src/runtime/fleet-needs-input-push.ts +4 -4
  95. package/src/runtime/fleet-services.ts +1 -1
  96. package/src/runtime/hosted-session-composition.ts +13 -13
  97. package/src/runtime/index.ts +1 -1
  98. package/src/runtime/knowledge-services.ts +2 -2
  99. package/src/runtime/legacy-daemon-migration.ts +43 -43
  100. package/src/runtime/legacy-daemon-reconcile.ts +30 -30
  101. package/src/runtime/mail-composition.ts +6 -6
  102. package/src/runtime/notification-dispatch.ts +7 -7
  103. package/src/runtime/payments-composition.ts +187 -0
  104. package/src/runtime/plugin-composition.ts +7 -7
  105. package/src/runtime/runtime-services-types.ts +9 -9
  106. package/src/runtime/services.ts +41 -32
  107. package/src/runtime/trigger-services.ts +1 -1
  108. package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
  109. package/src/runtime/trust/trust-gated-approvals.ts +9 -9
  110. package/src/runtime/update-check.ts +4 -4
  111. package/src/runtime/workspace-checkpointing.ts +6 -6
  112. package/src/testing/daemon-fixture.ts +11 -11
  113. package/src/testing/hosted-session-failures.ts +4 -4
  114. package/src/version.ts +2 -2
@@ -1,16 +1,16 @@
1
1
  /**
2
- * pairing-banner.ts the pairing block, in one place.
2
+ * pairing-banner.ts, the pairing block, in one place.
3
3
  *
4
4
  * A daemon prints this once, as it finishes starting. That was the ONLY way to
5
5
  * see it: scroll it off the screen, start the daemon as a service where nothing
6
6
  * reads stdout, or come back to the box tomorrow, and there was no way to get
7
7
  * the link back short of restarting the daemon. `goodvibes-daemon pair` prints
8
- * it again and prints exactly THIS, because both callers render from here.
8
+ * it again, and prints exactly THIS, because both callers render from here.
9
9
  *
10
10
  * It reprints the daemon's existing shared token rather than minting a new one.
11
11
  * Minting is a different act with a different consequence (a per-device token,
12
12
  * one more record in the token store, a link the boot banner's QR no longer
13
- * matches), and `pair` is not the command for it `pairing.handoff.create` is.
13
+ * matches), and `pair` is not the command for it, `pairing.handoff.create` is.
14
14
  */
15
15
  import {
16
16
  buildPairingHandoffLink,
@@ -28,7 +28,7 @@ export interface PairingBannerInput {
28
28
  readonly version: string;
29
29
  /** The web-app origin the deep link opens. */
30
30
  readonly origin: string;
31
- /** The token the link carries the daemon's existing shared companion token. */
31
+ /** The token the link carries, the daemon's existing shared companion token. */
32
32
  readonly token: string;
33
33
  readonly offers: readonly PairingHandoffOfferKind[];
34
34
  /** False to print the link and the offers without the QR block. */
@@ -69,7 +69,7 @@ export function renderPairingBanner(input: PairingBannerInput): PairingBanner {
69
69
  capabilities,
70
70
  notice,
71
71
  lines: [
72
- `GoodVibes daemon ${input.version} scan to pair a device (opens the web app signed in):`,
72
+ `GoodVibes daemon ${input.version}: scan to pair a device (opens the web app signed in):`,
73
73
  '',
74
74
  ` ${input.origin}`,
75
75
  '',
package/src/daemon/cli.ts CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  readControlPlaneBinding,
8
8
  resolveDaemonEnabled,
9
9
  } from '@pellux/goodvibes-sdk/platform/config';
10
- import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
11
10
  import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
11
+ import { isKnownConfigKey } from '../config/config-key-guard.ts';
12
12
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '@pellux/goodvibes-sdk/platform/providers';
13
13
  import { resolveGoodVibesHomeOwnership } from '@pellux/goodvibes-sdk/platform/config';
14
14
  import { RuntimeEventBus, GlobalNetworkTransportInstaller, configureRuntimeEventBusDefaults, runtimeEventBusOptionsFrom } from '@/runtime/index.ts';
@@ -72,15 +72,15 @@ import { renderPairingBanner } from '../core/pairing-banner.ts';
72
72
  import { readOperatorTokenFile } from '@pellux/goodvibes-sdk/platform/workspace';
73
73
  type DaemonCliOwnership = {
74
74
  readonly workingDirectory: string;
75
- /** The GoodVibes tree root settings, workspace, and discovery all hang off this. */
75
+ /** The GoodVibes tree root, settings, workspace, and discovery all hang off this. */
76
76
  readonly homeDirectory: string;
77
77
  /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
78
78
  readonly daemonHomeDirectory: string;
79
- /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override see goodvibes-home.ts. */
79
+ /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override, see goodvibes-home.ts. */
80
80
  readonly isOverridden: boolean;
81
81
  };
82
82
 
83
- // CLI flag parsing delegated to shared module see src/cli-flags.ts
83
+ // CLI flag parsing delegated to shared module, see src/cli-flags.ts
84
84
 
85
85
  type DaemonCliTokens = {
86
86
  readonly daemonToken: string | undefined;
@@ -90,11 +90,11 @@ type DaemonCliTokens = {
90
90
  /**
91
91
  * Two different directories that used to be one.
92
92
  *
93
- * `GOODVIBES_DAEMON_HOME` names the DAEMON's home the identity directory
93
+ * `GOODVIBES_DAEMON_HOME` names the DAEMON's home, the identity directory
94
94
  * holding auth-users.json, operator-tokens.json, and daemon-settings.json. That
95
95
  * is what the name says and what the SDK's `resolveDaemonHomeDir()` has always
96
96
  * meant by it. This function used to read it as the GoodVibes tree ROOT, so
97
- * setting it relocated settings, workspace, and every discovery root as well
97
+ * setting it relocated settings, workspace, and every discovery root as well,
98
98
  * far more than the daemon's own state.
99
99
  *
100
100
  * `GOODVIBES_HOME` is the variable for relocating the tree root. It is what a
@@ -104,7 +104,7 @@ type DaemonCliTokens = {
104
104
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
105
105
  // Both roots come from the SDK's platform/config goodvibes-home, which the CLIENT entry
106
106
  // point also uses. They were resolved independently, and the client's copy
107
- // simply did not read GOODVIBES_HOME so a redirected client wrote into the
107
+ // simply did not read GOODVIBES_HOME, so a redirected client wrote into the
108
108
  // real tree while the daemon honoured the redirect.
109
109
  const { homeDirectory, daemonHomeDirectory, isOverridden } = resolveGoodVibesHomeOwnership();
110
110
  return {
@@ -130,7 +130,7 @@ async function main(): Promise<void> {
130
130
  // Before the parser, because the subcommand has its own flag vocabulary
131
131
  // (--group, --key, --host, --port, --token) that the daemon parser would
132
132
  // reject as unknown. Before the runtime, because this command talks to a
133
- // daemon that is ALREADY RUNNING composing a second runtime here would
133
+ // daemon that is ALREADY RUNNING, composing a second runtime here would
134
134
  // build a competing set of state on a machine that already has one.
135
135
  //
136
136
  // See the terminal shell's cluster-remote-daemon-target for the
@@ -156,7 +156,7 @@ async function main(): Promise<void> {
156
156
  });
157
157
  // Every write below is immediately followed by process.exit, so all of them
158
158
  // go to the descriptor synchronously rather than through a stream that can
159
- // still be in flight when the process stops existing see
159
+ // still be in flight when the process stops existing, see
160
160
  // fatal-boot-report.ts. (The OSC 52 clipboard sequence is unterminated
161
161
  // either way; the line terminator this adds after it is inert.)
162
162
  if (result.rawOutput) writeExitingStdoutLine(`\u001b${result.rawOutput}`);
@@ -183,7 +183,7 @@ async function main(): Promise<void> {
183
183
  if (rawArgs[0] === 'send') {
184
184
  const ownership = resolveDaemonCliOwnership();
185
185
  // Named before the delivery runs, because the flushActivityLogSync() below
186
- // is what preserves this send's OUTBOUND_HTTP record and a logger with no
186
+ // is what preserves this send's OUTBOUND_HTTP record, and a logger with no
187
187
  // destination has nothing to flush, so that record was never written.
188
188
  configureActivityLogger(join(ownership.workingDirectory, '.goodvibes', 'logs'));
189
189
  runDaemonConfigMigration(ownership.homeDirectory);
@@ -214,7 +214,7 @@ async function main(): Promise<void> {
214
214
  // `provision-wake-model …` is intercepted here for the same two reasons, plus
215
215
  // its own: the curl installer runs it on the binary it has just placed, before
216
216
  // any daemon exists, and it must reach the SDK's pinned manifest rather than
217
- // have install.sh carry a second copy of the pins. It composes no runtime it
217
+ // have install.sh carry a second copy of the pins. It composes no runtime, it
218
218
  // resolves a home directory, derives the managed voice root the running daemon
219
219
  // uses, and calls one SDK function. See provision-wake-model.ts for why it
220
220
  // exits 0 on a failed download.
@@ -236,7 +236,7 @@ async function main(): Promise<void> {
236
236
  // placed, right after unpacking the web UI bundle, so that the key names, the
237
237
  // settings-tier routing and the "which listener actually serves this" question
238
238
  // all stay in one implementation instead of being copied into shell. It
239
- // composes no runtime it resolves a home directory, opens the same config
239
+ // composes no runtime, it resolves a home directory, opens the same config
240
240
  // manager the daemon boots with, and writes two or three keys. See
241
241
  // webui-command.ts.
242
242
  if (rawArgs[0] === 'webui') {
@@ -323,7 +323,7 @@ async function main(): Promise<void> {
323
323
  process.exit(0);
324
324
  }
325
325
  // `cluster`, `send`, `webui` and `provision-wake-model` are handled at the top
326
- // of this function, off the raw argument list, for the reasons stated there
326
+ // of this function, off the raw argument list, for the reasons stated there,
327
327
  // which means they only work as the FIRST word. Reaching one here means a
328
328
  // global flag was written in front of it. Refusing says so; falling through
329
329
  // would start a daemon in the foreground, which is the exact behaviour this
@@ -360,8 +360,8 @@ async function main(): Promise<void> {
360
360
  // Give the shared logger a destination before anything else runs. It never
361
361
  // had one in this entrypoint: `logger` only writes to a file once
362
362
  // `configureActivityLogger()` has named one, so every logger.info/warn/error
363
- // in this daemon including the fatal handler at the bottom of this file
364
- // and every flushActivityLogSync() call went nowhere at all. That is why
363
+ // in this daemon, including the fatal handler at the bottom of this file
364
+ // and every flushActivityLogSync() call, went nowhere at all. That is why
365
365
  // a crash-looping daemon left an empty activity log next to a silent
366
366
  // console. The terminal app's own src/cli/entrypoint.ts configures its
367
367
  // logger the same way at the same point in startup.
@@ -479,23 +479,23 @@ async function main(): Promise<void> {
479
479
 
480
480
  // Service lifecycle: install / uninstall / start / stop / restart / status /
481
481
  // migrate manage this host's service definition for the shared daemon. They
482
- // run BEFORE the daemon boots no runtime/services are constructed and
482
+ // run BEFORE the daemon boots, no runtime/services are constructed, and
483
483
  // exit with the honest result code.
484
484
  const serviceSubcommand = cli.command;
485
485
  if (isDaemonServiceSubcommand(serviceSubcommand)) {
486
486
  // The host/port baked into the unit's ExecStart (and displayed) come from
487
- // the SAME hostMode-aware resolution the SDK bind path uses never from
487
+ // the SAME hostMode-aware resolution the SDK bind path uses, never from
488
488
  // the GOODVIBES_DAEMON_HOST env var, which nothing in the bind path reads
489
489
  // (the --hostname flag already lands in config via
490
490
  // applyRuntimeEndpointFlagOverrides above, so it is covered here).
491
491
  const binding = resolveRuntimeEndpointBinding(config, 'controlPlane');
492
492
  if (!binding.recognized) {
493
- // The SDK bind path has no default case for an unrecognized hostMode
493
+ // The SDK bind path has no default case for an unrecognized hostMode,
494
494
  // a daemon launched with this config throws before binding. Say so
495
495
  // instead of presenting the fallback values as a real binding.
496
496
  console.warn(
497
497
  `[goodvibes-daemon] warning: controlPlane.hostMode '${binding.hostMode}' is not a recognized mode ` +
498
- "(local|network|custom) the daemon will fail to start until it is corrected.",
498
+ "(local|network|custom); the daemon will fail to start until it is corrected.",
499
499
  );
500
500
  }
501
501
  const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
@@ -504,12 +504,12 @@ async function main(): Promise<void> {
504
504
  binaryPath,
505
505
  homeDir: homeDirectory,
506
506
  // Unit-file paths resolve from the LOGIN home, never the
507
- // GOODVIBES_HOME-overridable tree home above see
507
+ // GOODVIBES_HOME-overridable tree home above, see
508
508
  // BuildManagedDaemonServiceManagerParams.unitHomeDir's doc.
509
509
  unitHomeDir: homedir(),
510
510
  host: binding.host,
511
511
  port: binding.port,
512
- // migrate-service only: never auto-migrate requires the same explicit
512
+ // migrate-service only: never auto-migrate, requires the same explicit
513
513
  // consent as any other non-interactive destructive confirmation.
514
514
  confirmMigration: cliFlags.yes,
515
515
  // service-status only.
@@ -521,7 +521,7 @@ async function main(): Promise<void> {
521
521
  portFlagProvided: cliFlags.port !== undefined,
522
522
  });
523
523
  // These print the unit path, the follow-up commands and the honest result,
524
- // then exit immediately exactly the race a stream write loses. Descriptor
524
+ // then exit immediately, exactly the race a stream write loses. Descriptor
525
525
  // writes instead.
526
526
  for (const line of result.lines) {
527
527
  writeExitingStdoutLine(line);
@@ -529,14 +529,14 @@ async function main(): Promise<void> {
529
529
  process.exit(result.exitCode);
530
530
  }
531
531
 
532
- // Honest startup identity, printed for EVERY launch shape including a bare
532
+ // Honest startup identity, printed for EVERY launch shape, including a bare
533
533
  // (no-arg) systemd launch, and BEFORE any runtime construction so it still
534
534
  // reaches the journal when a broken config makes the daemon throw during
535
535
  // composition. It states the resolved version (never a placeholder) and the
536
536
  // home/host/port the daemon will actually bind: the binding comes from the
537
537
  // SAME hostMode-aware resolution the SDK bind path uses (resolveHostBinding:
538
538
  // 'local' forces 127.0.0.1, 'network' forces 0.0.0.0, port 0/non-numeric
539
- // falls back to the default) never from controlPlane.host alone, and never
539
+ // falls back to the default), never from controlPlane.host alone, and never
540
540
  // from the GOODVIBES_DAEMON_HOST env var, which the bind path does not read.
541
541
  const bannerBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
542
542
  // eslint-disable-next-line no-console
@@ -547,14 +547,14 @@ async function main(): Promise<void> {
547
547
  // serving). Warn here so the journaled crash is explained.
548
548
  console.warn(
549
549
  `[goodvibes-daemon] warning: controlPlane.hostMode '${bannerBinding.hostMode}' is not a recognized mode ` +
550
- "(local|network|custom) the daemon cannot bind until it is corrected; the host/port above are fallback values, not a real binding.",
550
+ "(local|network|custom): the daemon cannot bind until it is corrected; the host/port above are fallback values, not a real binding.",
551
551
  );
552
552
  }
553
553
 
554
554
  // Boot-time reconciliation. The banner above states the real bind; this
555
555
  // compares it to the URL clients are actually handed. Those are produced by
556
556
  // two different resolvers, and when they disagree the daemon is advertising an
557
- // address it does not answer on the state that produced two different click
557
+ // address it does not answer on, the state that produced two different click
558
558
  // hosts from one daemon. The comparison is the SDK's own
559
559
  // describeDerivedBindMismatch, which already knows the two cases that are NOT
560
560
  // drift: a wildcard bind reported as 0.0.0.0 against a loopback dial target is
@@ -564,7 +564,9 @@ async function main(): Promise<void> {
564
564
  if (bannerBinding.recognized) {
565
565
  const mismatch = describeDerivedBindMismatch(
566
566
  { host: bannerBinding.host, port: bannerBinding.port },
567
- readControlPlaneBinding((key) => config.get(key as ConfigKey)),
567
+ readControlPlaneBinding((key) =>
568
+ isKnownConfigKey(key, config.getSchema()) ? config.get(key) : undefined,
569
+ ),
568
570
  );
569
571
  if (mismatch) {
570
572
  console.warn(`[goodvibes-daemon] warning: ${mismatch}`);
@@ -601,7 +603,7 @@ async function main(): Promise<void> {
601
603
  daemonHomeDirectory,
602
604
  // This daemon observes externally-launched coding-agent sessions on the
603
605
  // host read-only (fleet visibility + steer; never counted, never
604
- // stopped). Daemon-side only the interactive process reads this snapshot
606
+ // stopped). Daemon-side only, the interactive process reads this snapshot
605
607
  // rather than double-detecting. Mirrors the SDK daemon cli.
606
608
  observeExternalAgents: true,
607
609
  // Opt into the REAL host power seam (Linux logind: systemd-inhibit children
@@ -613,7 +615,7 @@ async function main(): Promise<void> {
613
615
  // The wake-word model ships with the installation, and this is the retry: at
614
616
  // every daemon start, sweep the managed wake tree and fetch whatever the
615
617
  // install could not (an offline install, a killed download, a changed pin).
616
- // It never blocks startup and never fails it see the SDK's
618
+ // It never blocks startup and never fails it, see the SDK's
617
619
  // voice/wake/install-provision.ts. The webui reads the model bytes from THIS
618
620
  // process, so a provisioned daemon is what makes the browser path work too.
619
621
  provisionWakeModelsAtBoot: true,
@@ -669,7 +671,7 @@ async function main(): Promise<void> {
669
671
  // against a REMOTE daemon behaves exactly like one run on that machine.
670
672
  clusterGroupVerbs: runtimeServices.clusterGroup.verbs,
671
673
  // Daemon-hosted sessions. Stating this is what turns `sessions.hosted.*`
672
- // on, and what it states is where a hosted run's asks are gated this
674
+ // on, and what it states is where a hosted run's asks are gated, this
673
675
  // daemon's per-workspace trust decision. See
674
676
  // runtime/hosted-session-composition.ts.
675
677
  hostedSessions: createHostedSessionOptions(runtimeServices),
@@ -733,7 +735,7 @@ async function main(): Promise<void> {
733
735
  // product's rather than the platform's: the legacy memory fold, provider
734
736
  // watching, and the notifier/integration attachments. They run AFTER
735
737
  // daemon.start(), because the fold has to follow the facade's memoryStore
736
- // init(), and none of them may keep the daemon from serving see
738
+ // init(), and none of them may keep the daemon from serving, see
737
739
  // runtime/boot-tasks.ts.
738
740
  await runDaemonBootTasks(runtimeServices);
739
741
 
@@ -756,11 +758,11 @@ async function main(): Promise<void> {
756
758
  // crash timeout.
757
759
  //
758
760
  // This process built the runtime graph and handed it to DaemonServer, so by
759
- // the SDK's ownership rule the facade leaves it alone nothing else stops
761
+ // the SDK's ownership rule the facade leaves it alone, nothing else stops
760
762
  // these pollers. Without dispose() the config watch, fleet tick, memory
761
763
  // governor, watcher registry and six more kept ticking until process exit.
762
764
  // The handler surfaces (inbox store + its poll timers, catalog handlers) are
763
- // the FIRST thing dispose() unwinds they are on the disposal owner list
765
+ // the FIRST thing dispose() unwinds, they are on the disposal owner list
764
766
  // now rather than sequenced by hand here, which is what makes every other
765
767
  // shutdown path stop them too instead of only this one.
766
768
  const stop = Promise.allSettled([listener.stop(), daemon.stop()])
@@ -768,7 +770,7 @@ async function main(): Promise<void> {
768
770
  .then(() => 'done' as const);
769
771
  const result = await Promise.race([stop, timeout]);
770
772
  if (result === 'timeout') {
771
- logger.warn('shutdown deadline exceeded forcing exit');
773
+ logger.warn('shutdown deadline exceeded; forcing exit');
772
774
  // A forced exit is exactly the case where the log matters most and is
773
775
  // least likely to have drained on its own.
774
776
  flushActivityLogSync();
@@ -795,16 +797,16 @@ async function main(): Promise<void> {
795
797
 
796
798
  // Cheap unattended reconcile: if this (canonical) daemon unit is confirmed
797
799
  // serving AND a redundant installer-managed goodvibes-daemon.service (the
798
- // retired unit name) sits enabled-but-NOT-running beside it the exact
799
- // production-incident state auto-disable and remove it, printing a
800
+ // retired unit name) sits enabled-but-NOT-running beside it, the exact
801
+ // production-incident state, auto-disable and remove it, printing a
800
802
  // receipt. A RUNNING legacy daemon, a hand-written unit, or an unanswered
801
803
  // configured endpoint all refuse with a notice instead. Best-effort: never
802
804
  // let this block or crash daemon boot (per-call systemctl timeouts plus one
803
805
  // cumulative pass deadline). The unit search root is the LOGIN user's home
804
806
  // (where systemd user units live), never the daemon data home
805
807
  // (GOODVIBES_DAEMON_HOME); the tracked name honors service.serviceName. The
806
- // endpoint requirement uses the CLIENT view of the config a fresh read of
807
- // settings.json with none of this process's runtime flag overrides because
808
+ // endpoint requirement uses the CLIENT view of the config, a fresh read of
809
+ // settings.json with none of this process's runtime flag overrides, because
808
810
  // that is what clients resolve when they look for the daemon.
809
811
  try {
810
812
  runDaemonConfigMigration(homeDirectory);
@@ -827,8 +829,8 @@ async function main(): Promise<void> {
827
829
  }
828
830
  }
829
831
  if (reconcile.reason !== 'no-legacy-unit') {
830
- // Breadcrumb for EVERY outcome where a legacy unit file exists
831
- // including guard refusals so a persisting two-unit state is never
832
+ // Breadcrumb for EVERY outcome where a legacy unit file exists,
833
+ // including guard refusals, so a persisting two-unit state is never
832
834
  // silent about why nothing was reconciled.
833
835
  logger.info('daemon: legacy-unit reconcile', { action: reconcile.action, reason: reconcile.reason });
834
836
  }
@@ -837,7 +839,7 @@ async function main(): Promise<void> {
837
839
  }
838
840
 
839
841
  // Print a device-pairing QR to stdout. The QR encodes the canonical
840
- // `#pair=<token>` deep link the web app consumes a camera scan opens it
842
+ // `#pair=<token>` deep link the web app consumes, a camera scan opens it
841
843
  // already signed in. No raw JSON connection blob is printed. This is also the
842
844
  // one place web.publicBaseUrl is frozen from the stable-name resolution (never
843
845
  // clobbering a user-set value), so the printed origin survives a DHCP change.
@@ -849,7 +851,7 @@ async function main(): Promise<void> {
849
851
  stepUpAvailable: true,
850
852
  });
851
853
  // Rendered by core/pairing-banner.ts, which `goodvibes-daemon pair` also
852
- // calls so the block printed here and the one printed on demand are the
854
+ // calls, so the block printed here and the one printed on demand are the
853
855
  // same block, from the same token, and cannot drift.
854
856
  const banner = renderPairingBanner({
855
857
  version: VERSION,
@@ -870,7 +872,7 @@ void main().catch((error) => {
870
872
  //
871
873
  // Doing it the other way round is what shipped mute. This handler used to
872
874
  // call logger.error and flushActivityLogSync and nothing else, and the
873
- // logger had no destination this early in boot so the released 1.27.0
875
+ // logger had no destination this early in boot, so the released 1.27.0
874
876
  // binary crash-looped 77 times with exit 1, zero bytes on stdout, zero bytes
875
877
  // on stderr and an empty activity log. See fatal-boot-report.ts.
876
878
  reportFatalBootFailure(error);
@@ -1,12 +1,12 @@
1
1
  /**
2
- * config-command.ts `goodvibes-daemon config list|get|set|unset`.
2
+ * config-command.ts, `goodvibes-daemon config list|get|set|unset`.
3
3
  *
4
4
  * A headless daemon's settings had no command at all: the only ways to change
5
5
  * one were to hand-edit a JSON file whose path and tier you had to already
6
6
  * know, or to attach a client. `webui enable` proved the pattern for a
7
- * settings-writing command that the installer and a person can both run open
7
+ * settings-writing command that the installer and a person can both run, open
8
8
  * the same ConfigManager the daemon boots with, write through `set()`, print an
9
- * honest receipt and this is that pattern generalised to every key.
9
+ * honest receipt, and this is that pattern generalised to every key.
10
10
  *
11
11
  * WHICH FILE A WRITE LANDS IN is not decided here. `ConfigManager.set()` routes
12
12
  * a daemon-owned key to the daemon's own settings store and a shared key to the
@@ -17,12 +17,12 @@
17
17
  * cli-redaction
18
18
  * first, so a token, a password, an API key or a card number reads as
19
19
  * `<redacted>` and a `config list` pasted into a bug report carries no
20
- * credential. Writes are NOT redacted `config set` stores the real value; it
20
+ * credential. Writes are NOT redacted, `config set` stores the real value; it
21
21
  * is the OUTPUT that is cleaned. A `goodvibes://secrets/...` reference is left
22
22
  * visible on purpose: it is a pointer, not a secret, and hiding it would make
23
23
  * the indirection impossible to verify.
24
24
  */
25
- import type { ConfigManager, ConfigKey, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
25
+ import type { ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
26
26
  import { ConfigError } from '@pellux/goodvibes-sdk/platform/config';
27
27
  import {
28
28
  REDACTED_VALUE,
@@ -30,6 +30,7 @@ import {
30
30
  parseConfigValueText,
31
31
  redactConfig,
32
32
  } from '@pellux/goodvibes-terminal-shell';
33
+ import { isKnownConfigKey } from '../config/config-key-guard.ts';
33
34
 
34
35
  export const CONFIG_SUBCOMMANDS = ['list', 'get', 'set', 'unset'] as const;
35
36
  export type ConfigSubcommand = (typeof CONFIG_SUBCOMMANDS)[number];
@@ -107,8 +108,9 @@ function message(error: unknown): string {
107
108
  }
108
109
 
109
110
  function readValue(deps: ConfigCommandDeps, key: string): unknown {
111
+ if (!isKnownConfigKey(key, deps.configManager.getSchema())) return undefined;
110
112
  try {
111
- return deps.configManager.get(key as ConfigKey);
113
+ return deps.configManager.get(key);
112
114
  } catch {
113
115
  return undefined;
114
116
  }
@@ -181,19 +183,18 @@ function getResult(deps: ConfigCommandDeps, key: string): ConfigCommandResult {
181
183
  }
182
184
 
183
185
  function setResult(deps: ConfigCommandDeps, key: string, rawValue: string): ConfigCommandResult {
184
- const setting = schemaFor(deps, key);
185
- if (!setting) {
186
+ if (!isKnownConfigKey(key, deps.configManager.getSchema())) {
186
187
  return failure(`'${key}' is not a settings key. Run \`${deps.binary ?? 'goodvibes-daemon'} config list\` to see them.`, deps);
187
188
  }
188
189
  const value = parseConfigValueText(rawValue);
189
190
  try {
190
- deps.configManager.set(key as ConfigKey, value as never);
191
+ deps.configManager.set(key, value as never);
191
192
  } catch (error) {
192
193
  // A schema refusal is the common case (wrong type, value outside an enum),
193
194
  // and its message already names what was wrong. Passing it through beats
194
195
  // rewording it into something vaguer.
195
196
  return failure(
196
- error instanceof ConfigError ? error.message : `could not write ${key} ${message(error)}`,
197
+ error instanceof ConfigError ? error.message : `could not write ${key}: ${message(error)}`,
197
198
  deps,
198
199
  );
199
200
  }
@@ -219,14 +220,13 @@ function setResult(deps: ConfigCommandDeps, key: string, rawValue: string): Conf
219
220
  }
220
221
 
221
222
  function unsetResult(deps: ConfigCommandDeps, key: string): ConfigCommandResult {
222
- const setting = schemaFor(deps, key);
223
- if (!setting) {
223
+ if (!isKnownConfigKey(key, deps.configManager.getSchema())) {
224
224
  return failure(`'${key}' is not a settings key. Run \`${deps.binary ?? 'goodvibes-daemon'} config list\` to see them.`, deps);
225
225
  }
226
226
  try {
227
- deps.configManager.reset(key as ConfigKey);
227
+ deps.configManager.reset(key);
228
228
  } catch (error) {
229
- return failure(`could not reset ${key} ${message(error)}`, deps);
229
+ return failure(`could not reset ${key}: ${message(error)}`, deps);
230
230
  }
231
231
  const stored = readValue(deps, key);
232
232
  if (deps.json) {
@@ -254,7 +254,7 @@ export function runConfigCommand(args: readonly string[], deps: ConfigCommandDep
254
254
  const subcommand = args[0];
255
255
  if (subcommand === undefined) return refusal('name what to do with the settings.', deps);
256
256
  if (!isConfigSubcommand(subcommand)) {
257
- return refusal(`'${subcommand}' is not a config command try ${CONFIG_SUBCOMMANDS.join(', ')}.`, deps);
257
+ return refusal(`'${subcommand}' is not a config command; try ${CONFIG_SUBCOMMANDS.join(', ')}.`, deps);
258
258
  }
259
259
 
260
260
  if (subcommand === 'list') {
@@ -3,7 +3,7 @@
3
3
  * former OperatorContext. It carries the SDK gateway catalog (handlers attach
4
4
  * to it), the daemon credential store, a read-only slice of the config manager,
5
5
  * resolved directories, and a logger. No SDK descriptor or schema is declared
6
- * here the catalog type is re-exported through the contracts seam.
6
+ * here, the catalog type is re-exported through the contracts seam.
7
7
  */
8
8
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
9
  import type { GatewayMethodCatalog } from './contracts.ts';
@@ -4,7 +4,7 @@
4
4
  * Every other module under `src/daemon/handlers/` imports SDK contract
5
5
  * identifiers from HERE and nowhere else. Concentrating the SDK imports in one
6
6
  * concrete-submodule module keeps the rest of the layer free of barrel cycles
7
- * and guarantees the host NEVER re-declares an SDK id, descriptor, or schema
7
+ * and guarantees the host NEVER re-declares an SDK id, descriptor, or schema,
8
8
  * it only attaches handlers to the descriptors the SDK already registered.
9
9
  */
10
10
 
@@ -17,6 +17,21 @@ export type {
17
17
  GatewayMethodHandler,
18
18
  } from '@pellux/goodvibes-sdk/platform/control-plane';
19
19
 
20
+ // The SDK's own `payments.*` route module: the registrar and the service seam
21
+ // it dispatches to. Exported by the SDK barrel as of 2.0.18 (CHANGELOG). Three
22
+ // of the seven verbs attach through this unchanged; see
23
+ // daemon/handlers/payments/register.ts and checkout-handlers.ts for why the
24
+ // other four (`cards.create`, `purchases.list`, `checkout.begin`,
25
+ // `checkout.fillCard`) stay local wrappers instead.
26
+ export { registerPaymentsGatewayMethods } from '@pellux/goodvibes-sdk/platform/control-plane';
27
+ export type { PaymentsGatewayService, PaymentPurchaseView } from '@pellux/goodvibes-sdk/platform/control-plane';
28
+
29
+ // The browser-checkout seam a daemon composition receives through
30
+ // `onBrowserCheckout` (sdk 2.0.19, platform/control-plane's
31
+ // `composeDaemonBrowser`). See daemon/handlers/payments/register.ts for how
32
+ // the checkout pair reads it.
33
+ export type { BrowserCheckoutSeam } from '@pellux/goodvibes-sdk/platform/control-plane';
34
+
20
35
  // Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
21
36
  export type {
22
37
  ChannelIdentity,
@@ -31,13 +46,13 @@ export type {
31
46
  * `remote.peers.*` HTTP routes can dispatch to it.
32
47
  *
33
48
  * Both were declared here, by hand, as a verbatim structural mirror of the
34
- * daemon-sdk's own declarations seventeen methods copied signature for
35
- * signature for one reason: neither carried the `export` keyword upstream,
49
+ * daemon-sdk's own declarations, seventeen methods copied signature for
50
+ * signature, for one reason: neither carried the `export` keyword upstream,
36
51
  * so neither could be imported. Both do now, and a mirror that can drift out of
37
52
  * agreement with the interface it must satisfy is worse than no mirror at all.
38
53
  *
39
54
  * They are re-exported under the same names so every implementer in this
40
55
  * product keeps naming them the way it already does. The SDK ships no
41
- * docker/ssh/cloud backend the host still owns the implementation.
56
+ * docker/ssh/cloud backend, the host still owns the implementation.
42
57
  */
43
58
  export type { DistributedRuntimeRouteService, RemotePeerAuth } from '@pellux/goodvibes-daemon-sdk/remote-routes';
@@ -20,7 +20,7 @@ export interface DaemonCredentialStore {
20
20
  * Store a daemon-owned credential. scope defaults 'daemon'.
21
21
  *
22
22
  * This is the DAEMON's own credential store: every caller is daemon code, and
23
- * every value written through it is one the daemon itself has to read back
23
+ * every value written through it is one the daemon itself has to read back,
24
24
  * including its own generated draft encryption key (createAtRestCipher below,
25
25
  * which calls put() with no scope at all). Defaulting to 'user' filed those
26
26
  * in a surface-scoped tier, so the daemon's own material lived somewhere it
@@ -8,14 +8,14 @@ import { HandlerSqliteStore } from '../sqlite-store.ts';
8
8
  // Mirrors the agent's local draft store (channels/drafts.json) server-side so
9
9
  // drafts are visible across surfaces. The agent's local file remains the source
10
10
  // of truth; this store is a sync mirror. Conflict resolution (most-recent
11
- // updatedAt wins) is the integrator's concern this store performs a plain
11
+ // updatedAt wins) is the integrator's concern, this store performs a plain
12
12
  // upsert and records the supplied/derived updatedAt.
13
13
  //
14
14
  // SECURITY POSTURE:
15
15
  // - The plaintext message body is NEVER persisted. It is encrypted at rest
16
16
  // via the daemon at-rest cipher (AES-256-GCM) as `bodyEnc`.
17
17
  // - The webhook URL is encrypted at rest (`webhookEnc`) and is ALWAYS
18
- // redacted ('[redacted]') in every list/get response the raw URL never
18
+ // redacted ('[redacted]') in every list/get response, the raw URL never
19
19
  // leaves the store.
20
20
  // - `messageDigest` (sha256First(body, 12)) is computed at save time and
21
21
  // stored alongside, so reads never need to decrypt the body. The required
@@ -170,7 +170,7 @@ function parseTags(raw: string | null): string[] | undefined {
170
170
  return tags.length > 0 ? tags : undefined;
171
171
  }
172
172
  } catch {
173
- // Corrupt tags column treat as absent rather than throwing on read.
173
+ // Corrupt tags column, treat as absent rather than throwing on read.
174
174
  }
175
175
  return undefined;
176
176
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The SDK owns every id, descriptor, access flag, and I/O schema (auto-
11
11
  * registered with handler:undefined). This module NEVER re-declares any of
12
- * them it looks each up via catalog.get(id) (inside registerCatalogHandler)
12
+ * them, it looks each up via catalog.get(id) (inside registerCatalogHandler)
13
13
  * and re-registers with the wrapped handler. save/delete are confirm-gated via
14
14
  * RegisterHandlerOptions.confirm; the wrapper enforces body.confirm === true
15
15
  * AND context.explicitUserRequest === true before the handler runs.
@@ -17,7 +17,7 @@
17
17
  * SECURITY: the message body is encrypted at rest and NEVER returned (the wire
18
18
  * `message` field carries the sha256First(body,12) digest). Webhooks are
19
19
  * encrypted at rest and ALWAYS redacted on read; RAW webhook tokens submitted
20
- * to save are REJECTED the agent must redact ('[redacted]') or pass a
20
+ * to save are REJECTED, the agent must redact ('[redacted]') or pass a
21
21
  * goodvibes://secrets/ reference before transmission.
22
22
  */
23
23
  import type { HandlerContext } from '../context.ts';
@@ -131,7 +131,7 @@ function optionalIso8601(value: unknown, field: string): string | undefined {
131
131
  * to be redacted before transmission; the daemon only stores '[redacted]' or a
132
132
  * goodvibes://secrets/ reference (which carries no token material). Any other
133
133
  * value bearing a scheme (e.g. an https:// URL with an embedded token) is a raw
134
- * secret and is rejected outright never persisted, never logged.
134
+ * secret and is rejected outright, never persisted, never logged.
135
135
  */
136
136
  function validateWebhook(value: string | undefined): string | undefined {
137
137
  if (value === undefined) return undefined;
@@ -287,7 +287,7 @@ export function registerDraftMethods(
287
287
  const id = parseDraftId(body);
288
288
  const record = store.get(id);
289
289
  if (record === null) return { notFound: true, id };
290
- // get output permits additional properties expose messageDigest explicitly
290
+ // get output permits additional properties, expose messageDigest explicitly
291
291
  // alongside the schema-required `message` field (both carry the digest).
292
292
  return { ...record, messageDigest: record.message };
293
293
  };