@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * daemon-ws-call.ts — invoking a verb that has no REST binding.
3
+ *
4
+ * Most control-plane verbs answer on a plain HTTP path and `callDaemonVerb` in
5
+ * @pellux/goodvibes-terminal-shell is all a subcommand needs. Some do not: the
6
+ * `sessions.hosted.*` family is declared ws-only in the method catalog (no
7
+ * `http` binding at all), because a hosted session's whole point is the event
8
+ * stream that comes with it. A GET against a path they do not have returns 404,
9
+ * which reads exactly like an out-of-date daemon.
10
+ *
11
+ * So this is the second half of the SAME convention: the target is resolved by
12
+ * `resolveRemoteDaemonTarget` (the --host/--port/--token flags, defaulting to
13
+ * this machine), the credential is the same operator token, and only the
14
+ * transport differs. Nothing here knows what any verb MEANS.
15
+ *
16
+ * The frames are the ones the operator contract declares for
17
+ * `/api/control-plane/ws`:
18
+ * -> {"type":"auth","token":"…"} <- {"type":"auth","ok":true,…}
19
+ * -> {"type":"call","id":"…","methodId":"…","body":{…}}
20
+ * <- {"type":"response","id":"…","ok":…,"status":…,"body":…}
21
+ *
22
+ * The Authorization header is sent on the upgrade as well as in the auth frame.
23
+ * The daemon requires it on the upgrade (an unauthenticated upgrade is a 401
24
+ * before any frame is read) and re-reads it from the frame; sending both is
25
+ * what makes one connection work for both checks.
26
+ */
27
+ import type { DaemonVerbOutcome, RemoteDaemonTarget } from '@pellux/goodvibes-terminal-shell';
28
+
29
+ /** How long a single verb call may take, upgrade included. */
30
+ export const DAEMON_WS_TIMEOUT_MS = 15_000;
31
+
32
+ /**
33
+ * The socket shape this module uses.
34
+ *
35
+ * Narrower than the platform `WebSocket` on purpose: a test double has no
36
+ * business implementing `binaryType`, `extensions` or the EventTarget surface,
37
+ * and requiring them would push every test into a cast.
38
+ */
39
+ export interface DaemonWebSocket {
40
+ send(data: string): void;
41
+ close(): void;
42
+ onopen: ((event: unknown) => void) | null;
43
+ onmessage: ((event: { data: unknown }) => void) | null;
44
+ onerror: ((event: unknown) => void) | null;
45
+ onclose: ((event: unknown) => void) | null;
46
+ }
47
+
48
+ export type DaemonWebSocketFactory = (
49
+ url: string,
50
+ init: { readonly headers: Readonly<Record<string, string>> },
51
+ ) => DaemonWebSocket;
52
+
53
+ /** The real one. Bun's WebSocket takes headers on the constructor; browsers' does not. */
54
+ const realWebSocketFactory: DaemonWebSocketFactory = (url, init) =>
55
+ new WebSocket(url, init as unknown as string[]) as unknown as DaemonWebSocket;
56
+
57
+ export interface CallDaemonWsVerbOptions {
58
+ readonly body?: unknown;
59
+ readonly timeoutMs?: number | undefined;
60
+ /** Injected in tests so nothing opens a socket. */
61
+ readonly socketFactory?: DaemonWebSocketFactory | undefined;
62
+ }
63
+
64
+ function wsUrlFor(baseUrl: string): string {
65
+ return `${baseUrl.replace(/^http/, 'ws')}/api/control-plane/ws`;
66
+ }
67
+
68
+ interface ParsedFrame {
69
+ readonly type?: unknown;
70
+ readonly ok?: unknown;
71
+ readonly id?: unknown;
72
+ readonly status?: unknown;
73
+ readonly body?: unknown;
74
+ readonly error?: unknown;
75
+ }
76
+
77
+ function parseFrame(data: unknown): ParsedFrame | null {
78
+ const text = typeof data === 'string'
79
+ ? data
80
+ : data instanceof Uint8Array
81
+ ? new TextDecoder().decode(data)
82
+ : null;
83
+ if (text === null) return null;
84
+ try {
85
+ const parsed = JSON.parse(text) as unknown;
86
+ return parsed && typeof parsed === 'object' ? (parsed as ParsedFrame) : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Call one ws-only verb and close.
94
+ *
95
+ * One connection per call. A CLI command asks one question and exits, so a
96
+ * pooled connection would only be a lifetime to get wrong; the cost is one
97
+ * upgrade against a daemon that is, in the default case, on this machine.
98
+ *
99
+ * Every failure shape becomes an `error`/`fix` pair in the same vocabulary
100
+ * `callDaemonVerb` produces, so a caller renders both the same way.
101
+ */
102
+ export async function callDaemonWsVerb<T>(
103
+ target: RemoteDaemonTarget,
104
+ methodId: string,
105
+ options: CallDaemonWsVerbOptions = {},
106
+ ): Promise<DaemonVerbOutcome<T>> {
107
+ const where = target.isLocal ? 'the daemon on this machine' : `the daemon at ${target.baseUrl}`;
108
+ const factory = options.socketFactory ?? realWebSocketFactory;
109
+ const timeoutMs = options.timeoutMs ?? DAEMON_WS_TIMEOUT_MS;
110
+ const callId = `cli-${methodId}-${Date.now()}`;
111
+
112
+ let socket: DaemonWebSocket;
113
+ try {
114
+ socket = factory(wsUrlFor(target.baseUrl), {
115
+ headers: { Authorization: `Bearer ${target.token}` },
116
+ });
117
+ } catch (error) {
118
+ return {
119
+ ok: false,
120
+ error: `could not reach ${where}`,
121
+ fix: target.isLocal
122
+ ? 'check the daemon is running: goodvibes-daemon service-status'
123
+ : `check that machine is reachable and its daemon is listening on ${target.baseUrl} `
124
+ + `(${error instanceof Error ? error.message : 'no further detail'})`,
125
+ };
126
+ }
127
+
128
+ return new Promise<DaemonVerbOutcome<T>>((resolve) => {
129
+ let settled = false;
130
+ const timer = setTimeout(() => {
131
+ finish({
132
+ ok: false,
133
+ error: `${where} did not answer within ${Math.round(timeoutMs / 1000)}s`,
134
+ fix: target.isLocal
135
+ ? 'check the daemon is running and not wedged: goodvibes-daemon status'
136
+ : 'check that machine is reachable and its daemon is not overloaded',
137
+ });
138
+ }, timeoutMs);
139
+
140
+ const finish = (outcome: DaemonVerbOutcome<T>): void => {
141
+ if (settled) return;
142
+ settled = true;
143
+ clearTimeout(timer);
144
+ try {
145
+ socket.close();
146
+ } catch {
147
+ // A socket that is already gone is exactly the state we wanted.
148
+ }
149
+ resolve(outcome);
150
+ };
151
+
152
+ socket.onopen = (): void => {
153
+ socket.send(JSON.stringify({ type: 'auth', token: target.token }));
154
+ };
155
+
156
+ socket.onmessage = (event): void => {
157
+ const frame = parseFrame(event.data);
158
+ if (!frame) return;
159
+
160
+ if (frame.type === 'auth') {
161
+ if (frame.ok === true) {
162
+ socket.send(JSON.stringify({
163
+ type: 'call',
164
+ id: callId,
165
+ methodId,
166
+ ...(options.body === undefined ? {} : { body: options.body }),
167
+ }));
168
+ return;
169
+ }
170
+ finish({
171
+ ok: false,
172
+ error: `${where} refused the operator token`,
173
+ fix: target.isLocal
174
+ ? 'the token may be stale — restart the daemon, or pass --token'
175
+ : 'pass --token with the operator token from that machine (its <daemon home>/operator-tokens.json)',
176
+ });
177
+ return;
178
+ }
179
+
180
+ if (frame.type === 'error') {
181
+ finish({
182
+ ok: false,
183
+ error: typeof frame.error === 'string' ? frame.error : `${where} refused the request`,
184
+ fix: 'run `goodvibes-daemon status` to see what that daemon is doing',
185
+ });
186
+ return;
187
+ }
188
+
189
+ if (frame.type !== 'response' || frame.id !== callId) return;
190
+
191
+ if (frame.ok === true) {
192
+ finish({ ok: true, data: frame.body as T });
193
+ return;
194
+ }
195
+ const body = (frame.body ?? {}) as { error?: unknown; fix?: unknown };
196
+ if (frame.status === 404) {
197
+ finish({
198
+ ok: false,
199
+ error: `${where} does not know the verb ${methodId}`,
200
+ fix: 'that daemon is running a build without this capability — update it, then try again',
201
+ });
202
+ return;
203
+ }
204
+ finish({
205
+ ok: false,
206
+ error: typeof body.error === 'string' ? body.error : `${where} refused ${methodId}`,
207
+ fix: typeof body.fix === 'string'
208
+ ? body.fix
209
+ : 'run `goodvibes-daemon status` to see what that daemon is doing',
210
+ });
211
+ };
212
+
213
+ socket.onerror = (): void => {
214
+ finish({
215
+ ok: false,
216
+ error: `could not reach ${where}`,
217
+ fix: target.isLocal
218
+ ? 'check the daemon is running: goodvibes-daemon service-status'
219
+ : `check that machine is switched on and its daemon is listening on ${target.baseUrl}`,
220
+ });
221
+ };
222
+
223
+ socket.onclose = (): void => {
224
+ // A close before an answer is a refusal too — most often the upgrade
225
+ // itself was rejected, which happens before any frame is sent.
226
+ finish({
227
+ ok: false,
228
+ error: `${where} closed the connection before answering`,
229
+ fix: target.isLocal
230
+ ? 'check the daemon is running: goodvibes-daemon service-status'
231
+ : 'check the operator token for that machine — an upgrade with no valid token is closed immediately',
232
+ });
233
+ };
234
+ });
235
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * raw-reply-route.ts — invoking a route that answers with its payload itself.
3
+ *
4
+ * `callDaemonVerb` in @pellux/goodvibes-terminal-shell reads the wrapped
5
+ * convention every `/api/cluster/*` route follows: `{ ok: true, data }` on
6
+ * success, `{ ok: false, error, fix }` on a refusal. Three routes this daemon
7
+ * serves do not — `/status`, `/api/health` and `/api/channels/status` answer
8
+ * with the payload ITSELF and put the verdict in the HTTP status. Reading one
9
+ * as the other is not a subtle failure: a raw payload has no `ok` field, so the
10
+ * wrapped reader called a perfectly healthy 200 "the daemon refused the
11
+ * request".
12
+ *
13
+ * So this is the second half of the SAME convention, next to daemon-ws-call.ts:
14
+ * the target is resolved by `resolveRemoteDaemonTarget`, the credential is the
15
+ * same operator token, the reachability / stale-credential / unreadable-reply
16
+ * refusals are the shared reader's — only the shape of a successful body
17
+ * differs. `rawReplyReader` restates a raw reply in the wrapped convention
18
+ * before the shared reader sees it, so there is one request path rather than
19
+ * two.
20
+ *
21
+ * Which routes are raw is stated per call, never sniffed: a payload is free to
22
+ * contain a field called `ok` and no sniffing rule could be honest about that.
23
+ */
24
+ import { callDaemonVerb, type DaemonFetch, type DaemonVerbOutcome, type RemoteDaemonTarget } from '@pellux/goodvibes-terminal-shell';
25
+
26
+ /** How a route's reply is shaped. */
27
+ export type DaemonReplyEnvelope = 'wrapped' | 'raw';
28
+
29
+ /**
30
+ * The status a restated reply carries.
31
+ *
32
+ * The shared reader looks at the status for exactly three verdicts — 401, 403
33
+ * and 404 — and those are passed through untouched below, before any body is
34
+ * read. Everything else it decides from the body, so a restated reply names a
35
+ * status that is legal to attach a body to (a 204 or a 304 is not) rather than
36
+ * echoing one that would make `new Response` throw.
37
+ */
38
+ const RESTATED_OK = 200;
39
+ const RESTATED_REFUSAL = 500;
40
+
41
+ /**
42
+ * Wrap a fetch so a raw-answering route reads as a wrapped one.
43
+ *
44
+ * A reply the shared reader short-circuits on (a credential refusal, an
45
+ * unknown path) is handed back exactly as it arrived. A body that is not JSON
46
+ * is handed back unparsed, so the shared reader produces its own
47
+ * "reply this build could not read" refusal rather than a second wording for
48
+ * the same thing.
49
+ */
50
+ export function rawReplyReader(target: RemoteDaemonTarget, fetchImpl: DaemonFetch = fetch): DaemonFetch {
51
+ const where = target.isLocal ? 'the daemon on this machine' : `the daemon at ${target.baseUrl}`;
52
+ return async (input, init) => {
53
+ const response = await fetchImpl(input, init);
54
+ if (response.status === 401 || response.status === 403 || response.status === 404) return response;
55
+
56
+ const text = await response.text();
57
+ let payload: unknown;
58
+ try {
59
+ payload = JSON.parse(text) as unknown;
60
+ } catch {
61
+ return new Response(text, { status: RESTATED_OK });
62
+ }
63
+
64
+ if (response.ok) {
65
+ return new Response(JSON.stringify({ ok: true, data: payload }), { status: RESTATED_OK });
66
+ }
67
+ const body = payload as { error?: unknown; fix?: unknown };
68
+ return new Response(JSON.stringify({
69
+ ok: false,
70
+ error: typeof body.error === 'string'
71
+ ? body.error
72
+ : `${where} answered ${response.status} for ${pathOf(input)}`,
73
+ fix: typeof body.fix === 'string'
74
+ ? body.fix
75
+ : 'run `goodvibes-daemon status` to see what that daemon is doing',
76
+ }), { status: RESTATED_REFUSAL });
77
+ };
78
+ }
79
+
80
+ /** The path portion of a request URL, for a refusal that names what was asked for. */
81
+ function pathOf(url: string): string {
82
+ try {
83
+ return new URL(url).pathname;
84
+ } catch {
85
+ return url;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Call a daemon route, saying which envelope it answers in.
91
+ *
92
+ * `wrapped` is the shared reader unchanged. `raw` is the shared reader with the
93
+ * restating fetch above in front of it, so both envelopes reach the same
94
+ * refusals in the same words.
95
+ */
96
+ export function callDaemonRoute<T>(
97
+ target: RemoteDaemonTarget,
98
+ path: string,
99
+ init: { method: 'GET' | 'POST'; body?: unknown; envelope?: DaemonReplyEnvelope } = { method: 'GET' },
100
+ fetchImpl: DaemonFetch = fetch,
101
+ ): Promise<DaemonVerbOutcome<T>> {
102
+ const request = init.body === undefined
103
+ ? { method: init.method }
104
+ : { method: init.method, body: init.body };
105
+ return callDaemonVerb<T>(
106
+ target,
107
+ path,
108
+ request,
109
+ (init.envelope ?? 'wrapped') === 'raw' ? rawReplyReader(target, fetchImpl) : fetchImpl,
110
+ );
111
+ }
@@ -0,0 +1,113 @@
1
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+
3
+ /**
4
+ * Checkpoint guard settings (`checkpoints.*` namespace).
5
+ *
6
+ * The SDK's WorkspaceCheckpointManager exposes root/retention guard options that
7
+ * decide which directory it is safe to snapshot and how the first sweep and
8
+ * retention behave:
9
+ *
10
+ * - `checkpoints.preferGitRoot` (boolean, SDK default true) — prefer the
11
+ * enclosing git repository's top level over the raw working directory.
12
+ * - `checkpoints.allowBroadRoot` (boolean, SDK default false) — opt in to
13
+ * snapshotting a broad root (filesystem root, home directory, ~/.goodvibes).
14
+ * - `checkpoints.allowLargeFirstSnapshot` (boolean, SDK default false) — opt
15
+ * in to a first snapshot whose full sweep exceeds `maxFirstSnapshotFiles`.
16
+ * - `checkpoints.maxFirstSnapshotFiles` (number, SDK default) — ceiling for
17
+ * the first-ever snapshot's file sweep.
18
+ * - `checkpoints.autoRetention` (boolean, SDK default true) — run a retention
19
+ * sweep automatically after each successful create and once at init.
20
+ *
21
+ * A sixth key, `checkpoints.unregisteredWorkspaces`, is read separately by
22
+ * `readCheckpointRegistrationSetting` below (kept out of `CheckpointGuardSettings`
23
+ * because it is a daemon-owned enforcement switch, not an SDK manager
24
+ * constructor option, and must never be spread into the manager's options).
25
+ *
26
+ * The shared SDK config schema (GoodVibesConfig) has no `checkpoints` category,
27
+ * so these are read directly from a user-supplied `checkpoints` block in
28
+ * settings.json. The SDK ConfigManager deep-merges loaded settings onto the
29
+ * default config and preserves unknown top-level blocks, so a hand-added
30
+ * `checkpoints` object survives to `getRaw()`. Only these five passthrough keys
31
+ * are read here; each absent key falls back to the SDK manager's own default.
32
+ *
33
+ * settings.json example:
34
+ *
35
+ * "checkpoints": {
36
+ * "preferGitRoot": true,
37
+ * "allowBroadRoot": false,
38
+ * "allowLargeFirstSnapshot": false,
39
+ * "maxFirstSnapshotFiles": 20000,
40
+ * "autoRetention": true,
41
+ * "unregisteredWorkspaces": "off"
42
+ * }
43
+ */
44
+ export interface CheckpointGuardSettings {
45
+ readonly preferGitRoot?: boolean;
46
+ readonly allowBroadRoot?: boolean;
47
+ readonly allowLargeFirstSnapshot?: boolean;
48
+ readonly maxFirstSnapshotFiles?: number;
49
+ readonly autoRetention?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Read the `checkpoints.*` guard passthrough keys from the loaded config. Only
54
+ * keys present with the right primitive type are returned; everything else is
55
+ * omitted so the SDK manager applies its own default. `maxFirstSnapshotFiles`
56
+ * must be a finite positive number to be accepted.
57
+ */
58
+ export function readCheckpointGuardSettings(
59
+ configManager: Pick<ConfigManager, 'getRaw'>,
60
+ ): CheckpointGuardSettings {
61
+ const raw = configManager.getRaw() as unknown as Record<string, unknown>;
62
+ const block = raw.checkpoints;
63
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) return {};
64
+ const cp = block as Record<string, unknown>;
65
+
66
+ const out: {
67
+ preferGitRoot?: boolean;
68
+ allowBroadRoot?: boolean;
69
+ allowLargeFirstSnapshot?: boolean;
70
+ maxFirstSnapshotFiles?: number;
71
+ autoRetention?: boolean;
72
+ } = {};
73
+
74
+ if (typeof cp.preferGitRoot === 'boolean') out.preferGitRoot = cp.preferGitRoot;
75
+ if (typeof cp.allowBroadRoot === 'boolean') out.allowBroadRoot = cp.allowBroadRoot;
76
+ if (typeof cp.allowLargeFirstSnapshot === 'boolean') out.allowLargeFirstSnapshot = cp.allowLargeFirstSnapshot;
77
+ if (
78
+ typeof cp.maxFirstSnapshotFiles === 'number'
79
+ && Number.isFinite(cp.maxFirstSnapshotFiles)
80
+ && cp.maxFirstSnapshotFiles > 0
81
+ ) {
82
+ out.maxFirstSnapshotFiles = cp.maxFirstSnapshotFiles;
83
+ }
84
+ if (typeof cp.autoRetention === 'boolean') out.autoRetention = cp.autoRetention;
85
+
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * The registered-workspaces-only override (owner ruling, 2026-07-10):
91
+ *
92
+ * - `checkpoints.unregisteredWorkspaces` (`'off' | 'guarded'`, default `'off'`)
93
+ * — `'off'` is the ruling's default: automatic (turn-end/lifecycle)
94
+ * checkpoints, and explicit checkpoint creation through the ws-only
95
+ * `checkpoints.create` gateway verb, both refuse when the resolved
96
+ * workspace root is not covered by the shared registration store
97
+ * (the SDK's workspace registration, platform/workspace).
98
+ * `'guarded'` opts back into the pre-ruling behavior for an unregistered
99
+ * workspace: automatic snapshots subscribe and explicit create proceeds,
100
+ * subject only to the SDK's own root/size guards above — never a silent
101
+ * re-enable, an explicit per-workspace opt-out of the registration gate.
102
+ *
103
+ * Any other value (including absence) reads as `'off'`.
104
+ */
105
+ export function readCheckpointRegistrationSetting(
106
+ configManager: Pick<ConfigManager, 'getRaw'>,
107
+ ): 'off' | 'guarded' {
108
+ const raw = configManager.getRaw() as unknown as Record<string, unknown>;
109
+ const block = raw.checkpoints;
110
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) return 'off';
111
+ const value = (block as Record<string, unknown>).unregisteredWorkspaces;
112
+ return value === 'guarded' ? 'guarded' : 'off';
113
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * run-daemon-config-migration.ts — the one call every composition root in
3
+ * this daemon makes before constructing its `ConfigManager`.
4
+ *
5
+ * Daemon-owned configuration now has exactly one home:
6
+ * `~/.goodvibes/daemon/settings.json`. Before this migration, every product
7
+ * wrote every key (including daemon-only ones like `surfaces.telegram.*`)
8
+ * into its own per-surface silo, and the daemon only ever read
9
+ * `~/.goodvibes/tui/settings.json` — so a value written by, say, the agent
10
+ * surface reported a successful save and configured nothing the daemon could
11
+ * see.
12
+ *
13
+ * `migrateDaemonOwnedConfig` (SDK, `platform/config`) is idempotent and cheap
14
+ * on the fast path (one file read + JSON parse), so it is safe and correct to
15
+ * call this at every composition root that is about to construct a
16
+ * `ConfigManager` — not just the first one to run in a given process. It
17
+ * must never abort startup: any failure is caught, logged with the marker
18
+ * path so the failure is diagnosable, and startup continues on whatever
19
+ * config state already exists.
20
+ */
21
+ import {
22
+ migrateDaemonOwnedConfig,
23
+ daemonConfigPath,
24
+ daemonConfigMovedPath,
25
+ type DaemonConfigMigrationResult,
26
+ } from '@pellux/goodvibes-sdk/platform/config';
27
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
28
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from './surface.ts';
29
+
30
+ /**
31
+ * Run the daemon-owned-config migration for `homeDir`, tolerating any
32
+ * failure. Returns the migration result on success, or `null` when the
33
+ * migration itself threw (logged as a warning naming the marker path;
34
+ * startup proceeds either way).
35
+ */
36
+ export function runDaemonConfigMigration(homeDir: string): DaemonConfigMigrationResult | null {
37
+ try {
38
+ return migrateDaemonOwnedConfig({ homeDir, primarySurface: GOODVIBES_DAEMON_SURFACE_ROOT });
39
+ } catch (error) {
40
+ const markerPath = daemonConfigMovedPath(daemonConfigPath(homeDir));
41
+ logger.warn('daemon-owned config migration failed; continuing with existing config state', {
42
+ markerPath,
43
+ error: summarizeError(error),
44
+ });
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,175 @@
1
+ import { isSecretRefInput, isDaemonOwnedConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
3
+ import type { SecretScope, SecretStorageMedium } from './secrets.ts';
4
+
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',
25
+ 'surfaces.slack.signingSecret',
26
+ 'surfaces.slack.botToken',
27
+ 'surfaces.slack.appToken',
28
+ 'surfaces.discord.botToken',
29
+ 'surfaces.ntfy.token',
30
+ 'surfaces.webhook.secret',
31
+ 'surfaces.homeassistant.accessToken',
32
+ 'surfaces.homeassistant.webhookSecret',
33
+ 'surfaces.telegram.botToken',
34
+ 'surfaces.telegram.webhookSecret',
35
+ 'surfaces.googleChat.verificationToken',
36
+ 'surfaces.signal.token',
37
+ 'surfaces.whatsapp.accessToken',
38
+ 'surfaces.whatsapp.verifyToken',
39
+ 'surfaces.whatsapp.signingSecret',
40
+ 'surfaces.imessage.token',
41
+ 'surfaces.msteams.appPassword',
42
+ 'surfaces.bluebubbles.password',
43
+ 'surfaces.mattermost.botToken',
44
+ 'surfaces.matrix.accessToken',
45
+ // 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,
55
+ ]);
56
+
57
+ export interface SecretBackedConfigUpdate {
58
+ readonly configValue: string;
59
+ readonly secretKey?: string;
60
+ readonly secretValue?: string;
61
+ readonly clearSecretKey?: string;
62
+ }
63
+
64
+ export interface SecretBackedConfigManager {
65
+ readonly get: (key: ConfigKey) => unknown;
66
+ readonly setDynamic: (key: ConfigKey, value: unknown) => void;
67
+ }
68
+
69
+ export interface SecretBackedSecretStore {
70
+ readonly set: (key: string, value: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
71
+ readonly delete?: (key: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
72
+ }
73
+
74
+ export function isSecretConfigKey(key: string): key is ConfigKey {
75
+ return SECRET_CONFIG_KEYS.has(key as ConfigKey);
76
+ }
77
+
78
+ export function normalizeSecretKeyPart(value: string): string {
79
+ return value
80
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
81
+ .replace(/[^a-zA-Z0-9]+/g, '_')
82
+ .replace(/^_+|_+$/g, '')
83
+ .toUpperCase();
84
+ }
85
+
86
+ export function buildGoodVibesSecretKey(configKey: string): string {
87
+ return `GOODVIBES_${configKey.split('.').map(normalizeSecretKeyPart).filter(Boolean).join('_')}`;
88
+ }
89
+
90
+ export function buildGoodVibesSecretRef(secretKey: string): string {
91
+ return `goodvibes://secrets/goodvibes/${encodeURIComponent(secretKey)}`;
92
+ }
93
+
94
+ export function isSecretReferenceValue(value: string): boolean {
95
+ const normalized = value.trim();
96
+ return normalized.startsWith('goodvibes://secrets/') && isSecretRefInput(normalized);
97
+ }
98
+
99
+ export function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
100
+ const normalized = value.trim();
101
+ return normalized.startsWith('goodvibes://') && !isSecretReferenceValue(normalized);
102
+ }
103
+
104
+ export function getSecretWriteMedium(policy: unknown): SecretStorageMedium {
105
+ if (policy === 'plaintext_allowed') return 'plaintext';
106
+ return 'secure';
107
+ }
108
+
109
+ export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: string): SecretBackedConfigUpdate {
110
+ const value = rawValue.trim();
111
+ const secretKey = buildGoodVibesSecretKey(configKey);
112
+ if (value.length === 0) {
113
+ return {
114
+ configValue: '',
115
+ clearSecretKey: secretKey,
116
+ };
117
+ }
118
+ if (isSecretReferenceValue(value)) {
119
+ return { configValue: value };
120
+ }
121
+ return {
122
+ configValue: buildGoodVibesSecretRef(secretKey),
123
+ secretKey,
124
+ secretValue: rawValue,
125
+ };
126
+ }
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
+
150
+ export async function persistSecretBackedConfigValue(
151
+ configManager: SecretBackedConfigManager,
152
+ secretsManager: SecretBackedSecretStore | null | undefined,
153
+ configKey: ConfigKey,
154
+ rawValue: string,
155
+ options: { readonly scope?: SecretScope } = {},
156
+ ): Promise<string> {
157
+ const update = buildSecretBackedConfigUpdate(configKey, rawValue);
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.
165
+ if (update.secretKey && update.secretValue !== undefined && secretsManager) {
166
+ await secretsManager.set(update.secretKey, update.secretValue, { scope, medium });
167
+ }
168
+
169
+ // 3. Clear old secret — pass the same medium so plaintext-medium secrets are found for deletion.
170
+ if (update.clearSecretKey && secretsManager?.delete) {
171
+ await secretsManager.delete(update.clearSecretKey, { scope, medium });
172
+ }
173
+
174
+ return update.configValue;
175
+ }