@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- 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
|
+
}
|