@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,331 @@
1
+ /**
2
+ * channels.drafts.* handler surface (Channel Drafts / Draft Sync Backend).
3
+ *
4
+ * Attaches host handlers to the four SDK-registered builtin descriptors:
5
+ * channels.drafts.list read:channels read-only
6
+ * channels.drafts.get read:channels read-only
7
+ * channels.drafts.save write:channels access:admin, confirm-gated
8
+ * channels.drafts.delete write:channels access:admin, dangerous, confirm-gated
9
+ *
10
+ * The SDK owns every id, descriptor, access flag, and I/O schema (auto-
11
+ * registered with handler:undefined). This module NEVER re-declares any of
12
+ * them — it looks each up via catalog.get(id) (inside registerCatalogHandler)
13
+ * and re-registers with the wrapped handler. save/delete are confirm-gated via
14
+ * RegisterHandlerOptions.confirm; the wrapper enforces body.confirm === true
15
+ * AND context.explicitUserRequest === true before the handler runs.
16
+ *
17
+ * SECURITY: the message body is encrypted at rest and NEVER returned (the wire
18
+ * `message` field carries the sha256First(body,12) digest). Webhooks are
19
+ * encrypted at rest and ALWAYS redacted on read; RAW webhook tokens submitted
20
+ * to save are REJECTED — the agent must redact ('[redacted]') or pass a
21
+ * goodvibes://secrets/ reference before transmission.
22
+ */
23
+ import type { HandlerContext } from '../context.ts';
24
+ import { createAtRestCipher } from '../credentials.ts';
25
+ import { HandlerError } from '../errors.ts';
26
+ import { registerCatalogHandlers } from '../register.ts';
27
+ import type { TypedHandler, Unregister } from '../register.ts';
28
+ import {
29
+ ALL_DRAFT_STATUSES,
30
+ DraftSyncStore,
31
+ MAX_DRAFT_LIST_LIMIT,
32
+ WRITABLE_DRAFT_STATUSES,
33
+ } from './draft-store.ts';
34
+ import type {
35
+ DraftListQuery,
36
+ DraftRecord,
37
+ DraftSaveInput,
38
+ DraftStatus,
39
+ } from './draft-store.ts';
40
+
41
+ const BAD_INPUT = 'INVALID_INPUT';
42
+
43
+ const LIST_ID = 'channels.drafts.list';
44
+ const GET_ID = 'channels.drafts.get';
45
+ const SAVE_ID = 'channels.drafts.save';
46
+ const DELETE_ID = 'channels.drafts.delete';
47
+
48
+ const writableStatusSet = new Set<string>(WRITABLE_DRAFT_STATUSES);
49
+ const allStatusSet = new Set<string>(ALL_DRAFT_STATUSES);
50
+
51
+ // --- Input validation helpers ----------------------------------------------
52
+
53
+ function asRecord(body: unknown): Record<string, unknown> {
54
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
55
+ throw new HandlerError('Request body must be an object.', BAD_INPUT, 400);
56
+ }
57
+ return body as Record<string, unknown>;
58
+ }
59
+
60
+ function optionalString(value: unknown, field: string): string | undefined {
61
+ if (value === undefined || value === null) return undefined;
62
+ if (typeof value !== 'string') {
63
+ throw new HandlerError(`Field '${field}' must be a string.`, BAD_INPUT, 400);
64
+ }
65
+ return value;
66
+ }
67
+
68
+ function requiredString(value: unknown, field: string): string {
69
+ const str = optionalString(value, field);
70
+ if (str === undefined || str.length === 0) {
71
+ throw new HandlerError(`Field '${field}' is required.`, BAD_INPUT, 400);
72
+ }
73
+ return str;
74
+ }
75
+
76
+ function optionalTags(value: unknown): string[] | undefined {
77
+ if (value === undefined || value === null) return undefined;
78
+ if (!Array.isArray(value)) {
79
+ throw new HandlerError("Field 'tags' must be an array of strings.", BAD_INPUT, 400);
80
+ }
81
+ const tags: string[] = [];
82
+ for (const entry of value) {
83
+ if (typeof entry !== 'string') {
84
+ throw new HandlerError("Field 'tags' must contain only strings.", BAD_INPUT, 400);
85
+ }
86
+ tags.push(entry);
87
+ }
88
+ return tags;
89
+ }
90
+
91
+ function optionalLimit(value: unknown): number | undefined {
92
+ if (value === undefined || value === null) return undefined;
93
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
94
+ throw new HandlerError("Field 'limit' must be an integer.", BAD_INPUT, 400);
95
+ }
96
+ if (value < 1 || value > MAX_DRAFT_LIST_LIMIT) {
97
+ throw new HandlerError(
98
+ `Field 'limit' must be between 1 and ${MAX_DRAFT_LIST_LIMIT}.`,
99
+ BAD_INPUT,
100
+ 400,
101
+ );
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function optionalVersion(value: unknown): number | undefined {
107
+ if (value === undefined || value === null) return undefined;
108
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
109
+ throw new HandlerError("Field 'version' must be a positive integer.", BAD_INPUT, 400);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ // Strict ISO-8601 date-time. Anchored so loosely-formatted but Date.parse-able
115
+ // values (e.g. '2020', 'Jan 1 2020', '2020-1-1') are rejected.
116
+ const ISO_8601_DATE_TIME =
117
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
118
+
119
+ function optionalIso8601(value: unknown, field: string): string | undefined {
120
+ const str = optionalString(value, field);
121
+ if (str === undefined) return undefined;
122
+ const time = ISO_8601_DATE_TIME.test(str) ? Date.parse(str) : Number.NaN;
123
+ if (Number.isNaN(time)) {
124
+ throw new HandlerError(`Field '${field}' must be an ISO-8601 timestamp.`, BAD_INPUT, 400);
125
+ }
126
+ return new Date(time).toISOString();
127
+ }
128
+
129
+ /**
130
+ * Reject RAW webhook tokens submitted to save. The contract requires webhooks
131
+ * to be redacted before transmission; the daemon only stores '[redacted]' or a
132
+ * goodvibes://secrets/ reference (which carries no token material). Any other
133
+ * value bearing a scheme (e.g. an https:// URL with an embedded token) is a raw
134
+ * secret and is rejected outright — never persisted, never logged.
135
+ */
136
+ function validateWebhook(value: string | undefined): string | undefined {
137
+ if (value === undefined) return undefined;
138
+ if (value === '' || value === '[redacted]') return value;
139
+ if (value.startsWith('goodvibes://secrets/')) return value;
140
+ if (value.includes('://')) {
141
+ throw new HandlerError(
142
+ "Field 'webhook' must be redacted before transmission: pass '[redacted]' "
143
+ + 'or a goodvibes://secrets/ reference, never a raw webhook URL.',
144
+ 'WEBHOOK_REDACTION_REQUIRED',
145
+ 400,
146
+ );
147
+ }
148
+ return value;
149
+ }
150
+
151
+ function parseListStatus(value: unknown): DraftStatus | undefined {
152
+ const str = optionalString(value, 'status');
153
+ if (str === undefined) return undefined;
154
+ if (!allStatusSet.has(str)) {
155
+ throw new HandlerError(
156
+ `Field 'status' must be one of: ${ALL_DRAFT_STATUSES.join(', ')}.`,
157
+ BAD_INPUT,
158
+ 400,
159
+ );
160
+ }
161
+ return str as DraftStatus;
162
+ }
163
+
164
+ function parseSaveStatus(value: unknown): DraftStatus | undefined {
165
+ const str = optionalString(value, 'status');
166
+ if (str === undefined) return undefined;
167
+ if (!writableStatusSet.has(str)) {
168
+ throw new HandlerError(
169
+ `Field 'status' must be one of: ${WRITABLE_DRAFT_STATUSES.join(', ')}.`,
170
+ BAD_INPUT,
171
+ 400,
172
+ );
173
+ }
174
+ return str as DraftStatus;
175
+ }
176
+
177
+ function parseListQuery(body: unknown): DraftListQuery {
178
+ const record = asRecord(body ?? {});
179
+ const query: DraftListQuery = {};
180
+ const status = parseListStatus(record.status);
181
+ if (status !== undefined) query.status = status;
182
+ const limit = optionalLimit(record.limit);
183
+ if (limit !== undefined) query.limit = limit;
184
+ return query;
185
+ }
186
+
187
+ function parseSaveInput(body: unknown): DraftSaveInput {
188
+ const record = asRecord(body);
189
+ const input: DraftSaveInput = {
190
+ message: requiredString(record.message, 'message'),
191
+ };
192
+ const id = optionalString(record.id, 'id');
193
+ if (id !== undefined) input.id = id;
194
+ const version = optionalVersion(record.version);
195
+ if (version !== undefined) input.version = version;
196
+ const title = optionalString(record.title, 'title');
197
+ if (title !== undefined) input.title = title;
198
+ const channel = optionalString(record.channel, 'channel');
199
+ if (channel !== undefined) input.channel = channel;
200
+ const route = optionalString(record.route, 'route');
201
+ if (route !== undefined) input.route = route;
202
+ const webhook = validateWebhook(optionalString(record.webhook, 'webhook'));
203
+ if (webhook !== undefined) input.webhook = webhook;
204
+ const link = optionalString(record.link, 'link');
205
+ if (link !== undefined) input.link = link;
206
+ const tags = optionalTags(record.tags);
207
+ if (tags !== undefined) input.tags = tags;
208
+ const status = parseSaveStatus(record.status);
209
+ if (status !== undefined) input.status = status;
210
+ const createdAt = optionalIso8601(record.createdAt, 'createdAt');
211
+ if (createdAt !== undefined) input.createdAt = createdAt;
212
+ const updatedAt = optionalIso8601(record.updatedAt, 'updatedAt');
213
+ if (updatedAt !== undefined) input.updatedAt = updatedAt;
214
+ return input;
215
+ }
216
+
217
+ /** Both get and delete identify a draft by `draftId` (per the SDK input schema). */
218
+ function parseDraftId(body: unknown): string {
219
+ const record = asRecord(body);
220
+ return requiredString(record.draftId, 'draftId');
221
+ }
222
+
223
+ // --- Response shapes (match the SDK output schemas) -------------------------
224
+
225
+ interface ListResponse {
226
+ drafts: DraftRecord[];
227
+ total: number;
228
+ }
229
+
230
+ /** get: a flat draft (CHANNEL_DRAFT_GET_OUTPUT permits additionalProperties) or a notFound marker. */
231
+ type GetResponse = (DraftRecord & { messageDigest: string }) | { notFound: true; id: string };
232
+
233
+ interface SaveResponse {
234
+ draft: DraftRecord;
235
+ created: boolean;
236
+ }
237
+
238
+ interface DeleteResponse {
239
+ deleted: boolean;
240
+ draftId: string;
241
+ }
242
+
243
+ export interface RegisterDraftsOptions {
244
+ /** Override the sqlite filename (tests). */
245
+ fileName?: string;
246
+ /** Inject a pre-built store (tests). When provided, the caller owns its lifecycle. */
247
+ store?: DraftSyncStore;
248
+ }
249
+
250
+ /**
251
+ * Register the channels.drafts.* handlers against the catalog held by `ctx`.
252
+ * Returns an Unregister that removes all four handlers (and closes the store
253
+ * when this function owns it).
254
+ *
255
+ * The store is created eagerly but initialized lazily on first invocation (the
256
+ * SQLite WASM load is deferred until a draft method is actually called). Every
257
+ * mutating handler persists the store via save() after the mutation so the
258
+ * mirror survives daemon restarts.
259
+ */
260
+ export function registerDraftMethods(
261
+ ctx: HandlerContext,
262
+ options: RegisterDraftsOptions = {},
263
+ ): Unregister {
264
+ const store =
265
+ options.store
266
+ ?? new DraftSyncStore({
267
+ workingDirectory: ctx.workingDirectory,
268
+ cipher: createAtRestCipher(ctx.credentials),
269
+ ...(options.fileName !== undefined ? { fileName: options.fileName } : {}),
270
+ });
271
+
272
+ let initPromise: Promise<void> | null = null;
273
+ const ensureInit = (): Promise<void> => {
274
+ if (!initPromise) initPromise = store.init();
275
+ return initPromise;
276
+ };
277
+
278
+ const listHandler: TypedHandler<unknown, ListResponse> = async ({ body }) => {
279
+ await ensureInit();
280
+ const query = parseListQuery(body);
281
+ const drafts = store.list(query);
282
+ return { drafts, total: store.count(query.status) };
283
+ };
284
+
285
+ const getHandler: TypedHandler<unknown, GetResponse> = async ({ body }) => {
286
+ await ensureInit();
287
+ const id = parseDraftId(body);
288
+ const record = store.get(id);
289
+ if (record === null) return { notFound: true, id };
290
+ // get output permits additional properties — expose messageDigest explicitly
291
+ // alongside the schema-required `message` field (both carry the digest).
292
+ return { ...record, messageDigest: record.message };
293
+ };
294
+
295
+ const saveHandler: TypedHandler<unknown, SaveResponse> = async ({ body }) => {
296
+ await ensureInit();
297
+ const input = parseSaveInput(body);
298
+ const result = await store.upsert(input);
299
+ await store.save();
300
+ return result;
301
+ };
302
+
303
+ const deleteHandler: TypedHandler<unknown, DeleteResponse> = async ({ body }) => {
304
+ await ensureInit();
305
+ const draftId = parseDraftId(body);
306
+ const deleted = store.delete(draftId);
307
+ if (deleted) await store.save();
308
+ return { deleted, draftId };
309
+ };
310
+
311
+ const unregister = registerCatalogHandlers(ctx.catalog, [
312
+ { id: LIST_ID, handler: listHandler as TypedHandler<unknown, unknown> },
313
+ { id: GET_ID, handler: getHandler as TypedHandler<unknown, unknown> },
314
+ {
315
+ id: SAVE_ID,
316
+ handler: saveHandler as TypedHandler<unknown, unknown>,
317
+ options: { confirm: true },
318
+ },
319
+ {
320
+ id: DELETE_ID,
321
+ handler: deleteHandler as TypedHandler<unknown, unknown>,
322
+ options: { confirm: true },
323
+ },
324
+ ]);
325
+
326
+ const ownsStore = options.store === undefined;
327
+ return () => {
328
+ unregister();
329
+ if (ownsStore) store.close();
330
+ };
331
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Error type for daemon handler execution. Carries a stable machine `code`
3
+ * and an HTTP `status` so the control-plane dispatcher can map failures to
4
+ * wire responses without leaking implementation detail.
5
+ */
6
+ export class HandlerError extends Error {
7
+ constructor(
8
+ message: string,
9
+ public readonly code: string,
10
+ public readonly status: number,
11
+ ) {
12
+ super(message);
13
+ this.name = 'HandlerError';
14
+ }
15
+ }
16
+
17
+ /** Code emitted when a confirmation-gated method is invoked without explicit user confirmation. */
18
+ export const REQUIRE_CONFIRM = 'REQUIRE_CONFIRM';