@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,281 @@
1
+ /**
2
+ * config-command.ts — `goodvibes-daemon config list|get|set|unset`.
3
+ *
4
+ * A headless daemon's settings had no command at all: the only ways to change
5
+ * one were to hand-edit a JSON file whose path and tier you had to already
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
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.
10
+ *
11
+ * WHICH FILE A WRITE LANDS IN is not decided here. `ConfigManager.set()` routes
12
+ * a daemon-owned key to the daemon's own settings store and a shared key to the
13
+ * shared one; this command reports where it landed rather than choosing, so the
14
+ * routing stays in one place and this command cannot disagree with the daemon.
15
+ *
16
+ * READS ARE REDACTED. Every value printed goes through the terminal shell's
17
+ * cli-redaction
18
+ * first, so a token, a password, an API key or a card number reads as
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
21
+ * is the OUTPUT that is cleaned. A `goodvibes://secrets/...` reference is left
22
+ * visible on purpose: it is a pointer, not a secret, and hiding it would make
23
+ * the indirection impossible to verify.
24
+ */
25
+ import type { ConfigManager, ConfigKey, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
26
+ import { ConfigError } from '@pellux/goodvibes-sdk/platform/config';
27
+ import {
28
+ REDACTED_VALUE,
29
+ isSensitiveConfigPath,
30
+ parseConfigValueText,
31
+ redactConfig,
32
+ } from '@pellux/goodvibes-terminal-shell';
33
+
34
+ export const CONFIG_SUBCOMMANDS = ['list', 'get', 'set', 'unset'] as const;
35
+ export type ConfigSubcommand = (typeof CONFIG_SUBCOMMANDS)[number];
36
+
37
+ export function isConfigSubcommand(value: string | undefined): value is ConfigSubcommand {
38
+ return typeof value === 'string' && (CONFIG_SUBCOMMANDS as readonly string[]).includes(value);
39
+ }
40
+
41
+ export interface ConfigCommandResult {
42
+ readonly exitCode: number;
43
+ readonly lines: readonly string[];
44
+ }
45
+
46
+ export interface ConfigCommandDeps {
47
+ /** The same manager the daemon boots with, so a write lands where the daemon reads. */
48
+ readonly configManager: Pick<ConfigManager, 'get' | 'set' | 'getSchema' | 'getRaw' | 'reset' | 'getConfigPath'>;
49
+ readonly json: boolean;
50
+ readonly binary?: string | undefined;
51
+ }
52
+
53
+ function usage(binary: string): string[] {
54
+ return [
55
+ ` ${binary} config list [--json]`,
56
+ ` ${binary} config get <key> [--json]`,
57
+ ` ${binary} config set <key> <value>`,
58
+ ` ${binary} config unset <key>`,
59
+ ];
60
+ }
61
+
62
+ function refusal(message: string, deps: ConfigCommandDeps): ConfigCommandResult {
63
+ const binary = deps.binary ?? 'goodvibes-daemon';
64
+ return {
65
+ exitCode: 2,
66
+ lines: deps.json
67
+ ? [JSON.stringify({ ok: false, error: message }, null, 2)]
68
+ : [`config: ${message}`, ...usage(binary)],
69
+ };
70
+ }
71
+
72
+ function failure(message: string, deps: ConfigCommandDeps): ConfigCommandResult {
73
+ return {
74
+ exitCode: 1,
75
+ lines: deps.json ? [JSON.stringify({ ok: false, error: message }, null, 2)] : [`config: ${message}`],
76
+ };
77
+ }
78
+
79
+ /**
80
+ * The redacted, printable form of one value.
81
+ *
82
+ * The path decides, not the value: `isSensitiveConfigPath` answers for the key
83
+ * name, so an empty password prints as empty (it IS unset) and a real one
84
+ * prints as `<redacted>` whatever it happens to contain. A secrets reference is
85
+ * a pointer and stays visible.
86
+ */
87
+ export function renderConfigValue(key: string, value: unknown): string {
88
+ if (isSensitiveConfigPath(key)) {
89
+ if (value === null || value === undefined) return 'unset';
90
+ if (typeof value === 'string') {
91
+ if (value.trim().length === 0) return '""';
92
+ if (value.startsWith('goodvibes://secrets/')) return value;
93
+ return REDACTED_VALUE;
94
+ }
95
+ return value === false || value === 0 ? JSON.stringify(value) : REDACTED_VALUE;
96
+ }
97
+ if (value === undefined) return 'unset';
98
+ return typeof value === 'string' ? value : JSON.stringify(value);
99
+ }
100
+
101
+ function schemaFor(deps: ConfigCommandDeps, key: string): ConfigSetting | undefined {
102
+ return deps.configManager.getSchema().find((setting) => setting.key === key);
103
+ }
104
+
105
+ function message(error: unknown): string {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+
109
+ function readValue(deps: ConfigCommandDeps, key: string): unknown {
110
+ try {
111
+ return deps.configManager.get(key as ConfigKey);
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ function listResult(deps: ConfigCommandDeps): ConfigCommandResult {
118
+ const schema = deps.configManager.getSchema();
119
+ const rows = schema.map((setting) => ({
120
+ key: setting.key,
121
+ value: readValue(deps, setting.key),
122
+ type: setting.type,
123
+ }));
124
+
125
+ if (deps.json) {
126
+ // The whole document goes through the recursive redactor as well as the
127
+ // per-key render, so a secret nested inside an object-valued setting is
128
+ // caught by its own path rather than only by its parent's name.
129
+ const redacted = redactConfig(
130
+ Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<string, unknown>,
131
+ );
132
+ return {
133
+ exitCode: 0,
134
+ lines: [JSON.stringify({
135
+ ok: true,
136
+ data: {
137
+ settingsFile: deps.configManager.getConfigPath(),
138
+ redactedKeys: redacted.redactedPaths,
139
+ settings: redacted.value,
140
+ },
141
+ }, null, 2)],
142
+ };
143
+ }
144
+
145
+ const width = rows.reduce((max, row) => Math.max(max, row.key.length), 0);
146
+ return {
147
+ exitCode: 0,
148
+ lines: [
149
+ `${rows.length} settings (values that read like credentials are shown as ${REDACTED_VALUE}):`,
150
+ '',
151
+ ...rows.map((row) => ` ${row.key.padEnd(width)} ${renderConfigValue(row.key, row.value)}`),
152
+ '',
153
+ `settings file: ${deps.configManager.getConfigPath()}`,
154
+ 'a daemon-owned key is written to the daemon\'s own settings store instead; `config set` says which.',
155
+ ],
156
+ };
157
+ }
158
+
159
+ function getResult(deps: ConfigCommandDeps, key: string): ConfigCommandResult {
160
+ const setting = schemaFor(deps, key);
161
+ if (!setting) {
162
+ return failure(`'${key}' is not a settings key. Run \`${deps.binary ?? 'goodvibes-daemon'} config list\` to see them.`, deps);
163
+ }
164
+ const value = readValue(deps, key);
165
+ if (deps.json) {
166
+ const redacted = redactConfig({ [key]: value } as Record<string, unknown>);
167
+ return {
168
+ exitCode: 0,
169
+ lines: [JSON.stringify({
170
+ ok: true,
171
+ data: {
172
+ key,
173
+ value: (redacted.value as Record<string, unknown>)[key],
174
+ type: setting.type,
175
+ redacted: redacted.redactedPaths.length > 0,
176
+ },
177
+ }, null, 2)],
178
+ };
179
+ }
180
+ return { exitCode: 0, lines: [renderConfigValue(key, value)] };
181
+ }
182
+
183
+ function setResult(deps: ConfigCommandDeps, key: string, rawValue: string): ConfigCommandResult {
184
+ const setting = schemaFor(deps, key);
185
+ if (!setting) {
186
+ return failure(`'${key}' is not a settings key. Run \`${deps.binary ?? 'goodvibes-daemon'} config list\` to see them.`, deps);
187
+ }
188
+ const value = parseConfigValueText(rawValue);
189
+ try {
190
+ deps.configManager.set(key as ConfigKey, value as never);
191
+ } catch (error) {
192
+ // A schema refusal is the common case (wrong type, value outside an enum),
193
+ // and its message already names what was wrong. Passing it through beats
194
+ // rewording it into something vaguer.
195
+ return failure(
196
+ error instanceof ConfigError ? error.message : `could not write ${key} — ${message(error)}`,
197
+ deps,
198
+ );
199
+ }
200
+ const stored = readValue(deps, key);
201
+ if (deps.json) {
202
+ const redacted = redactConfig({ [key]: stored } as Record<string, unknown>);
203
+ return {
204
+ exitCode: 0,
205
+ lines: [JSON.stringify({
206
+ ok: true,
207
+ data: { key, value: (redacted.value as Record<string, unknown>)[key], written: true },
208
+ }, null, 2)],
209
+ };
210
+ }
211
+ return {
212
+ exitCode: 0,
213
+ lines: [
214
+ `${key} = ${renderConfigValue(key, stored)}`,
215
+ ' written to disk. A running daemon picks most keys up live; the ones that only apply',
216
+ ' when it binds a port need a restart: goodvibes-daemon restart-service',
217
+ ],
218
+ };
219
+ }
220
+
221
+ function unsetResult(deps: ConfigCommandDeps, key: string): ConfigCommandResult {
222
+ const setting = schemaFor(deps, key);
223
+ if (!setting) {
224
+ return failure(`'${key}' is not a settings key. Run \`${deps.binary ?? 'goodvibes-daemon'} config list\` to see them.`, deps);
225
+ }
226
+ try {
227
+ deps.configManager.reset(key as ConfigKey);
228
+ } catch (error) {
229
+ return failure(`could not reset ${key} — ${message(error)}`, deps);
230
+ }
231
+ const stored = readValue(deps, key);
232
+ if (deps.json) {
233
+ const redacted = redactConfig({ [key]: stored } as Record<string, unknown>);
234
+ return {
235
+ exitCode: 0,
236
+ lines: [JSON.stringify({
237
+ ok: true,
238
+ data: { key, value: (redacted.value as Record<string, unknown>)[key], reset: true },
239
+ }, null, 2)],
240
+ };
241
+ }
242
+ return {
243
+ exitCode: 0,
244
+ lines: [`${key} = ${renderConfigValue(key, stored)} (back to its shipped default)`],
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Run one config verb. Never throws for an ordinary refusal: a bad key, a value
250
+ * the schema rejects and an unwritable settings file all come back as an exit
251
+ * code and lines, because a caller is often a script reading both.
252
+ */
253
+ export function runConfigCommand(args: readonly string[], deps: ConfigCommandDeps): ConfigCommandResult {
254
+ const subcommand = args[0];
255
+ if (subcommand === undefined) return refusal('name what to do with the settings.', deps);
256
+ if (!isConfigSubcommand(subcommand)) {
257
+ return refusal(`'${subcommand}' is not a config command — try ${CONFIG_SUBCOMMANDS.join(', ')}.`, deps);
258
+ }
259
+
260
+ if (subcommand === 'list') {
261
+ if (args.length > 1) return refusal(`'${args[1]}' is one argument too many.`, deps);
262
+ return listResult(deps);
263
+ }
264
+
265
+ const key = args[1];
266
+ if (key === undefined) return refusal(`${subcommand} needs a settings key.`, deps);
267
+
268
+ if (subcommand === 'get') {
269
+ if (args.length > 2) return refusal(`'${args[2]}' is one argument too many.`, deps);
270
+ return getResult(deps, key);
271
+ }
272
+ if (subcommand === 'unset') {
273
+ if (args.length > 2) return refusal(`'${args[2]}' is one argument too many.`, deps);
274
+ return unsetResult(deps, key);
275
+ }
276
+
277
+ const rawValue = args[2];
278
+ if (rawValue === undefined) return refusal(`set needs a value: \`config set ${key} <value>\`.`, deps);
279
+ if (args.length > 3) return refusal(`'${args[3]}' is one argument too many.`, deps);
280
+ return setResult(deps, key, rawValue);
281
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Host-facing context passed to every surface register function. Replaces the
3
+ * former OperatorContext. It carries the SDK gateway catalog (handlers attach
4
+ * to it), the daemon credential store, a read-only slice of the config manager,
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.
7
+ */
8
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
+ import type { GatewayMethodCatalog } from './contracts.ts';
10
+ import type { DaemonCredentialStore } from './credentials.ts';
11
+ import type { Unregister } from './register.ts';
12
+
13
+ export interface HandlerLogger {
14
+ info(message: string, meta?: unknown): void;
15
+ warn(message: string, meta?: unknown): void;
16
+ error(message: string, meta?: unknown): void;
17
+ }
18
+
19
+ export interface HandlerContext {
20
+ readonly catalog: GatewayMethodCatalog;
21
+ readonly credentials: DaemonCredentialStore;
22
+ readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
23
+ readonly workingDirectory: string;
24
+ readonly homeDirectory: string;
25
+ readonly logger: HandlerLogger;
26
+ }
27
+
28
+ /** Every surface module exports a register function of this shape. */
29
+ export type SurfaceRegister = (ctx: HandlerContext) => Unregister;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Single SDK-contract import seam for the daemon handler layer.
3
+ *
4
+ * Every other module under `src/daemon/handlers/` imports SDK contract
5
+ * identifiers from HERE and nowhere else. Concentrating the SDK imports in one
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 —
8
+ * it only attaches handlers to the descriptors the SDK already registered.
9
+ */
10
+
11
+ // Catalog + invocation contract types (concrete control-plane subpath, not a project barrel).
12
+ export type {
13
+ GatewayMethodCatalog,
14
+ GatewayMethodDescriptor,
15
+ GatewayMethodInvocation,
16
+ GatewayMethodInvocationContext,
17
+ GatewayMethodHandler,
18
+ } from '@pellux/goodvibes-sdk/platform/control-plane';
19
+
20
+ // Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
21
+ export type {
22
+ ChannelIdentity,
23
+ ChannelResolvedTarget,
24
+ ChannelAccountRecord,
25
+ } from '@pellux/goodvibes-sdk/platform/channels';
26
+
27
+ /**
28
+ * The two remote-route contracts a host has to name: the per-peer auth
29
+ * envelope, and the distributed-runtime service the SDK facade injects into
30
+ * `DaemonRemoteRouteContext.distributedRuntime` so the published
31
+ * `remote.peers.*` HTTP routes can dispatch to it.
32
+ *
33
+ * 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,
36
+ * so neither could be imported. Both do now, and a mirror that can drift out of
37
+ * agreement with the interface it must satisfy is worse than no mirror at all.
38
+ *
39
+ * They are re-exported under the same names so every implementer in this
40
+ * product keeps naming them the way it already does. The SDK ships no
41
+ * docker/ssh/cloud backend — the host still owns the implementation.
42
+ */
43
+ export type { DistributedRuntimeRouteService, RemotePeerAuth } from '@pellux/goodvibes-daemon-sdk/remote-routes';
@@ -0,0 +1,139 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import type { SecretsManager, SecretScope } from '../../config/secrets.ts';
3
+ import {
4
+ buildGoodVibesSecretKey,
5
+ isSecretReferenceValue,
6
+ } from '../../config/secret-config.ts';
7
+
8
+ export interface DaemonCredentialStore {
9
+ /**
10
+ * Resolve a goodvibes://secrets/ reference OR a raw secret key to its
11
+ * plaintext value (daemon-internal only).
12
+ */
13
+ resolveRef(ref: string): Promise<string | null>;
14
+ /**
15
+ * Resolve a config-key-derived secret
16
+ * (e.g. 'surfaces.slack.botToken' → GOODVIBES_SURFACES_SLACK_BOT_TOKEN).
17
+ */
18
+ resolveConfigSecret(configKey: string): Promise<string | null>;
19
+ /**
20
+ * Store a daemon-owned credential. scope defaults 'daemon'.
21
+ *
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 —
24
+ * including its own generated draft encryption key (createAtRestCipher below,
25
+ * which calls put() with no scope at all). Defaulting to 'user' filed those
26
+ * in a surface-scoped tier, so the daemon's own material lived somewhere it
27
+ * only found by accident, and a value written on one surface's behalf was
28
+ * invisible to the next.
29
+ */
30
+ put(
31
+ secretKey: string,
32
+ value: string,
33
+ opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
34
+ ): Promise<void>;
35
+ has(secretKey: string): Promise<boolean>;
36
+ }
37
+
38
+ /** Extract the trailing secret-key segment from a goodvibes://secrets/ reference. */
39
+ function secretKeyFromReference(ref: string): string {
40
+ const normalized = ref.trim();
41
+ const segments = normalized.split('/');
42
+ const last = segments[segments.length - 1] ?? '';
43
+ return decodeURIComponent(last);
44
+ }
45
+
46
+ export function createDaemonCredentialStore(secrets: SecretsManager): DaemonCredentialStore {
47
+ return {
48
+ async resolveRef(ref: string): Promise<string | null> {
49
+ const key = isSecretReferenceValue(ref) ? secretKeyFromReference(ref) : ref;
50
+ return secrets.get(key);
51
+ },
52
+ async resolveConfigSecret(configKey: string): Promise<string | null> {
53
+ return secrets.get(buildGoodVibesSecretKey(configKey));
54
+ },
55
+ async put(
56
+ secretKey: string,
57
+ value: string,
58
+ opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
59
+ ): Promise<void> {
60
+ await secrets.set(secretKey, value, {
61
+ scope: opts?.scope ?? 'daemon',
62
+ medium: opts?.medium ?? 'secure',
63
+ });
64
+ },
65
+ async has(secretKey: string): Promise<boolean> {
66
+ const value = await secrets.get(secretKey);
67
+ return value !== null && value.length > 0;
68
+ },
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // At-rest encryption helper for drafts (draft body must be encrypted at rest).
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export interface AtRestCipher {
77
+ encrypt(plaintext: string): Promise<string>;
78
+ decrypt(ciphertext: string): Promise<string>;
79
+ }
80
+
81
+ const DEFAULT_DRAFT_KEY_NAME = 'GOODVIBES_DAEMON_DRAFT_AESKEY';
82
+ const AES_KEY_BYTES = 32; // AES-256
83
+ const GCM_IV_BYTES = 12;
84
+ const GCM_TAG_BYTES = 16;
85
+
86
+ async function loadOrCreateKey(
87
+ store: DaemonCredentialStore,
88
+ keyName: string,
89
+ ): Promise<Buffer> {
90
+ const existing = await store.resolveRef(keyName);
91
+ if (existing && existing.length > 0) {
92
+ const buf = Buffer.from(existing, 'base64');
93
+ if (buf.length === AES_KEY_BYTES) return buf;
94
+ }
95
+ const generated = randomBytes(AES_KEY_BYTES);
96
+ await store.put(keyName, generated.toString('base64'), { medium: 'secure' });
97
+ return generated;
98
+ }
99
+
100
+ /**
101
+ * Create an AES-256-GCM at-rest cipher backed by a daemon-owned key.
102
+ * encrypt() returns base64(iv | tag | ciphertext). Uses node:crypto (Bun-compatible).
103
+ * NEVER log resolved keys or plaintext.
104
+ */
105
+ export function createAtRestCipher(
106
+ store: DaemonCredentialStore,
107
+ keyName: string = DEFAULT_DRAFT_KEY_NAME,
108
+ ): AtRestCipher {
109
+ let keyPromise: Promise<Buffer> | null = null;
110
+ const getKey = (): Promise<Buffer> => {
111
+ if (!keyPromise) keyPromise = loadOrCreateKey(store, keyName);
112
+ return keyPromise;
113
+ };
114
+
115
+ return {
116
+ async encrypt(plaintext: string): Promise<string> {
117
+ const key = await getKey();
118
+ const iv = randomBytes(GCM_IV_BYTES);
119
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
120
+ const encrypted = Buffer.concat([
121
+ cipher.update(plaintext, 'utf-8'),
122
+ cipher.final(),
123
+ ]);
124
+ const tag = cipher.getAuthTag();
125
+ return Buffer.concat([iv, tag, encrypted]).toString('base64');
126
+ },
127
+ async decrypt(ciphertext: string): Promise<string> {
128
+ const key = await getKey();
129
+ const raw = Buffer.from(ciphertext, 'base64');
130
+ const iv = raw.subarray(0, GCM_IV_BYTES);
131
+ const tag = raw.subarray(GCM_IV_BYTES, GCM_IV_BYTES + GCM_TAG_BYTES);
132
+ const data = raw.subarray(GCM_IV_BYTES + GCM_TAG_BYTES);
133
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
134
+ decipher.setAuthTag(tag);
135
+ const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
136
+ return decrypted.toString('utf-8');
137
+ },
138
+ };
139
+ }