@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,127 @@
1
+ // Shared subprocess runner built on Bun.spawn. Captures stdout/stderr/exit code
2
+ // with a hard timeout. Used by every backend that shells out (docker/ssh/cloud/
3
+ // local-process). No credentials are ever passed as argv — callers pass key
4
+ // material via files or the `env` overlay.
5
+
6
+ export interface RunOptions {
7
+ args: string[];
8
+ cwd?: string;
9
+ env?: Record<string, string>;
10
+ stdin?: string;
11
+ timeoutMs: number;
12
+ }
13
+
14
+ export interface RunResult {
15
+ exitCode: number;
16
+ stdout: string;
17
+ stderr: string;
18
+ timedOut: boolean;
19
+ }
20
+
21
+ interface BunSubprocessLike {
22
+ readonly stdout: ReadableStream<Uint8Array> | null;
23
+ readonly stderr: ReadableStream<Uint8Array> | null;
24
+ readonly stdin: { write(chunk: string): void; end(): void | Promise<void> } | null;
25
+ readonly exited: Promise<number>;
26
+ kill(signal?: number | string): void;
27
+ }
28
+
29
+ interface BunSpawnOptions {
30
+ cwd?: string;
31
+ env?: Record<string, string | undefined>;
32
+ stdin?: 'pipe' | 'ignore';
33
+ stdout: 'pipe';
34
+ stderr: 'pipe';
35
+ }
36
+
37
+ type BunSpawn = (cmd: string[], options: BunSpawnOptions) => BunSubprocessLike;
38
+
39
+ function getBunSpawn(): BunSpawn {
40
+ const globalBun = (globalThis as { Bun?: { spawn?: unknown } }).Bun;
41
+ if (!globalBun || typeof globalBun.spawn !== 'function') {
42
+ throw new Error('Bun.spawn is unavailable in this runtime.');
43
+ }
44
+ return globalBun.spawn as unknown as BunSpawn;
45
+ }
46
+
47
+ async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
48
+ if (!stream) return '';
49
+ const reader = stream.getReader();
50
+ const decoder = new TextDecoder();
51
+ let out = '';
52
+ try {
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ if (value) out += decoder.decode(value, { stream: true });
57
+ }
58
+ out += decoder.decode();
59
+ } finally {
60
+ reader.releaseLock();
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Spawn a subprocess and capture its output with a hard timeout. The first
67
+ * element of `args` is the executable. Throws only on spawn failure; non-zero
68
+ * exit codes are returned in the result.
69
+ *
70
+ * On timeout the child is killed with SIGKILL and `child.exited` is awaited so
71
+ * no orphaned subprocess is left running after the timeout fires.
72
+ */
73
+ export async function runProcess(options: RunOptions): Promise<RunResult> {
74
+ if (options.args.length === 0) {
75
+ throw new Error('runProcess requires at least one argument (the executable).');
76
+ }
77
+ const spawn = getBunSpawn();
78
+ const mergedEnv: Record<string, string | undefined> = {
79
+ ...process.env,
80
+ ...(options.env ?? {}),
81
+ };
82
+
83
+ const child = spawn(options.args, {
84
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
85
+ env: mergedEnv,
86
+ stdin: options.stdin !== undefined ? 'pipe' : 'ignore',
87
+ stdout: 'pipe',
88
+ stderr: 'pipe',
89
+ });
90
+
91
+ if (options.stdin !== undefined && child.stdin) {
92
+ child.stdin.write(options.stdin);
93
+ await child.stdin.end();
94
+ }
95
+
96
+ let timedOut = false;
97
+ let timer: ReturnType<typeof setTimeout> | undefined;
98
+ const timeoutPromise = new Promise<void>((resolve) => {
99
+ timer = setTimeout(() => {
100
+ timedOut = true;
101
+ try {
102
+ child.kill('SIGKILL');
103
+ } catch {
104
+ // process may have already exited
105
+ }
106
+ resolve();
107
+ }, options.timeoutMs);
108
+ });
109
+
110
+ // Always await the child's real exit. On timeout we SIGKILL above, then the
111
+ // race resolves via child.exited (which settles once the kill takes effect),
112
+ // guaranteeing the subprocess is reaped rather than orphaned.
113
+ const [stdout, stderr, exitCode] = await Promise.all([
114
+ readStream(child.stdout),
115
+ readStream(child.stderr),
116
+ Promise.race([child.exited, timeoutPromise.then(() => child.exited)]),
117
+ ]);
118
+
119
+ if (timer) clearTimeout(timer);
120
+
121
+ return {
122
+ exitCode: typeof exitCode === 'number' ? exitCode : -1,
123
+ stdout,
124
+ stderr,
125
+ timedOut,
126
+ };
127
+ }
@@ -0,0 +1,126 @@
1
+ import { mkdir, writeFile, rm, chmod } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import type { PeerRecord } from '../peer-registry.ts';
5
+ import type { SshBackendConfig } from '../peer-registry.ts';
6
+ import {
7
+ type Backend,
8
+ type BackendContext,
9
+ type BackendDispatchResult,
10
+ type DispatchPayload,
11
+ BackendDispatchError,
12
+ resolveTimeout,
13
+ buildRemoteShellCommand,
14
+ } from './types.ts';
15
+ import { runProcess } from './process-runner.ts';
16
+ import { tokenizeCommand } from './local-process.ts';
17
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
18
+
19
+ /**
20
+ * Persistent-key material is written to {homeDirectory}/.goodvibes/tui/operator/
21
+ * ssh-keys/{peerId}.key with 0600 permissions and reused across invocations
22
+ * (connection pooling via the OpenSSH ControlMaster multiplexer). The key value
23
+ * itself comes only from the daemon credential store — never argv, never logs.
24
+ */
25
+ interface PooledIdentity {
26
+ keyPath: string;
27
+ controlPath: string;
28
+ identityRef: string;
29
+ }
30
+
31
+ export function createSshBackend(ctx: BackendContext): Backend {
32
+ const pool = new Map<string, PooledIdentity>();
33
+ const keyDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'ssh-keys');
34
+
35
+ async function ensureIdentity(
36
+ peer: PeerRecord,
37
+ config: SshBackendConfig,
38
+ ): Promise<PooledIdentity> {
39
+ const existing = pool.get(peer.peerId);
40
+ if (existing && existing.identityRef === config.identityRef) {
41
+ return existing;
42
+ }
43
+ const key = await ctx.credentials.resolveRef(config.identityRef);
44
+ if (!key || key.length === 0) {
45
+ throw new BackendDispatchError(
46
+ `Could not resolve SSH identity for peer '${peer.peerId}'.`,
47
+ 'REMOTE_BACKEND_CREDENTIAL_MISSING',
48
+ );
49
+ }
50
+ await mkdir(keyDir, { recursive: true });
51
+ await chmod(keyDir, 0o700).catch(() => {});
52
+ const suffix = randomBytes(4).toString('hex');
53
+ const keyPath = join(keyDir, `${peer.peerId}.${suffix}.key`);
54
+ const normalizedKey = key.endsWith('\n') ? key : `${key}\n`;
55
+ await writeFile(keyPath, normalizedKey, { mode: 0o600 });
56
+ await chmod(keyPath, 0o600).catch(() => {});
57
+ const controlPath = join(keyDir, `${peer.peerId}.${suffix}.ctl`);
58
+ const identity: PooledIdentity = { keyPath, controlPath, identityRef: config.identityRef };
59
+ // Replace any prior identity for this peer and clean up its key file.
60
+ if (existing) await rm(existing.keyPath, { force: true }).catch(() => {});
61
+ pool.set(peer.peerId, identity);
62
+ return identity;
63
+ }
64
+
65
+ return {
66
+ kind: 'ssh',
67
+ async dispatch(
68
+ peer: PeerRecord,
69
+ command: string,
70
+ payload?: DispatchPayload,
71
+ ): Promise<BackendDispatchResult> {
72
+ if (peer.backendConfig.kind !== 'ssh') {
73
+ throw new BackendDispatchError(
74
+ `Peer '${peer.peerId}' is not an ssh peer.`,
75
+ 'REMOTE_BACKEND_KIND_MISMATCH',
76
+ );
77
+ }
78
+ const config = peer.backendConfig as { kind: 'ssh' } & SshBackendConfig;
79
+ if (tokenizeCommand(command).length === 0) {
80
+ throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
81
+ }
82
+ const identity = await ensureIdentity(peer, config);
83
+ const port = config.sshPort ?? 22;
84
+ const target = `${config.sshUser}@${config.sshHost}`;
85
+ const remoteCommand = buildRemoteShellCommand(command, payload?.args);
86
+
87
+ const args = [
88
+ 'ssh',
89
+ '-i', identity.keyPath,
90
+ '-p', String(port),
91
+ '-o', 'StrictHostKeyChecking=accept-new',
92
+ '-o', 'BatchMode=yes',
93
+ '-o', 'ConnectTimeout=15',
94
+ // Connection pooling: reuse a multiplexed master for ~60s.
95
+ '-o', 'ControlMaster=auto',
96
+ '-o', `ControlPath=${identity.controlPath}`,
97
+ '-o', 'ControlPersist=60',
98
+ target,
99
+ remoteCommand,
100
+ ];
101
+
102
+ ctx.logger.info('remote ssh dispatch', {
103
+ peerId: peer.peerId,
104
+ host: config.sshHost,
105
+ port,
106
+ });
107
+ const result = await runProcess({
108
+ args,
109
+ timeoutMs: resolveTimeout(payload),
110
+ ...(payload?.env !== undefined ? { env: payload.env } : {}),
111
+ ...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
112
+ });
113
+ return {
114
+ exitCode: result.timedOut ? 124 : result.exitCode,
115
+ stdout: result.stdout,
116
+ stderr: result.timedOut ? `${result.stderr}\n[remote] ssh command timed out` : result.stderr,
117
+ };
118
+ },
119
+ async teardown(): Promise<void> {
120
+ // Sweep every pooled key file plus the ssh-keys/ dir so no private-key
121
+ // material (or stale ControlMaster sockets) outlives the daemon process.
122
+ pool.clear();
123
+ await rm(keyDir, { recursive: true, force: true }).catch(() => {});
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,97 @@
1
+ import type { DaemonCredentialStore } from '../../credentials.ts';
2
+ import type { HandlerLogger } from '../../context.ts';
3
+ import type { PeerRecord } from '../peer-registry.ts';
4
+
5
+ /**
6
+ * Payload accepted alongside a command on remote.peers.invoke. All fields are
7
+ * optional; backends interpret what they support.
8
+ */
9
+ export interface DispatchPayload {
10
+ /** Positional args appended to the command (already tokenized). */
11
+ args?: string[];
12
+ /** Data piped to the process stdin. */
13
+ stdin?: string;
14
+ /** Per-invocation environment overlay (never includes secrets). */
15
+ env?: Record<string, string>;
16
+ /** Hard timeout for synchronous execution, in milliseconds. */
17
+ timeoutMs?: number;
18
+ /** Working directory override (backend-dependent). */
19
+ cwd?: string;
20
+ }
21
+
22
+ export interface BackendDispatchResult {
23
+ exitCode?: number;
24
+ workId?: string;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
28
+
29
+ export interface BackendContext {
30
+ credentials: DaemonCredentialStore;
31
+ logger: HandlerLogger;
32
+ /** Daemon home dir — used for ephemeral key material under a 0700 subdir. */
33
+ homeDirectory: string;
34
+ }
35
+
36
+ /**
37
+ * A remote execution backend. Each backend dispatches a single command for a
38
+ * given peer and returns the captured stdout/stderr plus an exit code.
39
+ *
40
+ * Backends must NEVER place raw credentials in the returned stdout/stderr or in
41
+ * any thrown error message. Credentials are resolved internally from the daemon
42
+ * credential store via secret references on the peer's backendConfig.
43
+ */
44
+ export interface Backend {
45
+ readonly kind: PeerRecord['backendKind'];
46
+ dispatch(
47
+ peer: PeerRecord,
48
+ command: string,
49
+ payload?: DispatchPayload,
50
+ ): Promise<BackendDispatchResult>;
51
+ /**
52
+ * Best-effort cleanup of any on-disk material the backend created (ephemeral
53
+ * key/credential files and their containing dirs). Called from the surface
54
+ * teardown so secrets do not outlive the daemon process. Optional: backends
55
+ * that write nothing to disk omit it.
56
+ */
57
+ teardown?(): Promise<void>;
58
+ }
59
+
60
+ export const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
61
+ export const MAX_SYNC_TIMEOUT_MS = 600_000;
62
+
63
+ export function resolveTimeout(payload?: DispatchPayload): number {
64
+ const requested = payload?.timeoutMs;
65
+ if (typeof requested === 'number' && Number.isFinite(requested) && requested > 0) {
66
+ return Math.min(requested, MAX_SYNC_TIMEOUT_MS);
67
+ }
68
+ return DEFAULT_SYNC_TIMEOUT_MS;
69
+ }
70
+
71
+ export class BackendDispatchError extends Error {
72
+ readonly code: string;
73
+ constructor(message: string, code = 'REMOTE_BACKEND_DISPATCH_FAILED') {
74
+ super(message);
75
+ this.name = 'BackendDispatchError';
76
+ this.code = code;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Compose the remote-shell command line for the shell-style backends
82
+ * (docker `sh -c`, ssh remote command, cloud-CLI `--command`/`--scripts`).
83
+ *
84
+ * REMOTE-SHELL SEMANTICS (intentional, documented asymmetry vs local-process):
85
+ * positional `payload.args` are joined onto the command with a single space and
86
+ * are NOT shell-escaped, because these backends hand a single command STRING to
87
+ * a remote shell — the operator's `command` may itself contain pipes, redirects,
88
+ * globs, or quoting that must survive the hop verbatim. The local-process
89
+ * backend, by contrast, never invokes a shell and passes args as discrete argv.
90
+ *
91
+ * This surface is operator/admin-gated (the SDK route enforces
92
+ * confirmation/explicitUserRequest before dispatch), so callers that need
93
+ * literal arguments must pre-quote them inside `command` or `args`.
94
+ */
95
+ export function buildRemoteShellCommand(command: string, args?: string[]): string {
96
+ return args && args.length > 0 ? `${command} ${args.join(' ')}` : command;
97
+ }
@@ -0,0 +1,181 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { HandlerLogger } from '../context.ts';
3
+ import type { DaemonCredentialStore } from '../credentials.ts';
4
+ import { PeerRegistry, type PeerRecord } from './peer-registry.ts';
5
+ import {
6
+ type Backend,
7
+ type BackendContext,
8
+ type DispatchPayload,
9
+ BackendDispatchError,
10
+ createBackends,
11
+ } from './backends/index.ts';
12
+
13
+ /** SHA-256 of input, truncated to the first `hexChars` hex characters. */
14
+ function sha256First(input: string, hexChars: number): string {
15
+ const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
16
+ return digest.slice(0, Math.max(0, hexChars));
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Work-item hook — long-running invocations are enqueued as work items visible
21
+ // in remote.work.list. The dispatcher does not own the distributed runtime; the
22
+ // integrator wires this hook to the DistributedRuntimeManager work queue.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface RemoteWorkItemInput {
26
+ peerId: string;
27
+ command: string;
28
+ payload?: DispatchPayload;
29
+ /** Echoed onto the work item so the runner can pick the right backend. */
30
+ backendKind: PeerRecord['backendKind'];
31
+ queuedBy: string;
32
+ }
33
+
34
+ export interface RemoteWorkEnqueuer {
35
+ enqueue(item: RemoteWorkItemInput): Promise<{ workId: string }>;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Invoke result — returned to the agent through remote.peers.invoke. Includes
40
+ // stdoutDigest (sha256 of FULL stdout, 64 hex chars) per the receipt contract.
41
+ // The agent may receive only a truncated stdout preview.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export const STDOUT_PREVIEW_LIMIT = 4_096;
45
+
46
+ export interface RemoteInvokeResult {
47
+ peerId: string;
48
+ backendKind: PeerRecord['backendKind'];
49
+ /** Present for synchronous completion. */
50
+ exitCode?: number;
51
+ /** Present for async/long-running dispatch. */
52
+ workId?: string;
53
+ completed: boolean;
54
+ stdout: string;
55
+ stderr: string;
56
+ /** SHA-256 of the full stdout, 64 hex chars. */
57
+ stdoutDigest: string;
58
+ }
59
+
60
+ export interface RemoteDispatcherOptions {
61
+ registry: PeerRegistry;
62
+ credentials: DaemonCredentialStore;
63
+ logger: HandlerLogger;
64
+ homeDirectory: string;
65
+ /** Optional hook for enqueuing long-running work items. */
66
+ workEnqueuer?: RemoteWorkEnqueuer;
67
+ /** Override the backend map (tests inject fakes). */
68
+ backends?: Map<PeerRecord['backendKind'], Backend>;
69
+ }
70
+
71
+ export interface DispatchRequest {
72
+ peerId: string;
73
+ command: string;
74
+ payload?: DispatchPayload;
75
+ /** Principal that requested the dispatch (for work-item attribution). */
76
+ principalId: string;
77
+ /** When true (and a work enqueuer exists), run as an async work item. */
78
+ async?: boolean;
79
+ }
80
+
81
+ function truncate(value: string, limit: number): string {
82
+ return value.length > limit ? value.slice(0, limit) : value;
83
+ }
84
+
85
+ /**
86
+ * Routes remote.peers.invoke commands to the correct execution backend.
87
+ * Synchronous commands return an exitCode; long-running commands (async:true
88
+ * with a configured work enqueuer) return a workId tracked via remote.work.list.
89
+ */
90
+ export class RemoteDispatcher {
91
+ private readonly registry: PeerRegistry;
92
+ private readonly backends: Map<PeerRecord['backendKind'], Backend>;
93
+ private readonly workEnqueuer?: RemoteWorkEnqueuer;
94
+ private readonly logger: HandlerLogger;
95
+
96
+ constructor(options: RemoteDispatcherOptions) {
97
+ this.registry = options.registry;
98
+ this.logger = options.logger;
99
+ if (options.workEnqueuer) this.workEnqueuer = options.workEnqueuer;
100
+ const backendContext: BackendContext = {
101
+ credentials: options.credentials,
102
+ logger: options.logger,
103
+ homeDirectory: options.homeDirectory,
104
+ };
105
+ this.backends = options.backends ?? createBackends(backendContext);
106
+ }
107
+
108
+ async dispatch(request: DispatchRequest): Promise<RemoteInvokeResult> {
109
+ const peerId = typeof request.peerId === 'string' ? request.peerId.trim() : '';
110
+ if (peerId.length === 0) {
111
+ throw new BackendDispatchError('peerId is required.', 'REMOTE_PEER_ID_REQUIRED');
112
+ }
113
+ const command = typeof request.command === 'string' ? request.command : '';
114
+ if (command.trim().length === 0) {
115
+ throw new BackendDispatchError('command is required.', 'REMOTE_COMMAND_REQUIRED');
116
+ }
117
+ const peer = this.registry.get(peerId);
118
+ if (!peer) {
119
+ throw new BackendDispatchError(
120
+ `No registered peer with id '${peerId}'.`,
121
+ 'REMOTE_PEER_NOT_FOUND',
122
+ );
123
+ }
124
+ const backend = this.backends.get(peer.backendKind);
125
+ if (!backend) {
126
+ throw new BackendDispatchError(
127
+ `No backend available for kind '${peer.backendKind}'.`,
128
+ 'REMOTE_BACKEND_UNAVAILABLE',
129
+ );
130
+ }
131
+
132
+ // Async path: enqueue a work item and return its id immediately.
133
+ if (request.async === true && this.workEnqueuer) {
134
+ const { workId } = await this.workEnqueuer.enqueue({
135
+ peerId: peer.peerId,
136
+ command,
137
+ backendKind: peer.backendKind,
138
+ queuedBy: request.principalId,
139
+ ...(request.payload !== undefined ? { payload: request.payload } : {}),
140
+ });
141
+ this.logger.info('remote invoke enqueued', { peerId: peer.peerId, workId });
142
+ return {
143
+ peerId: peer.peerId,
144
+ backendKind: peer.backendKind,
145
+ workId,
146
+ completed: false,
147
+ stdout: '',
148
+ stderr: '',
149
+ stdoutDigest: sha256First('', 64),
150
+ };
151
+ }
152
+
153
+ // Synchronous path: run on the backend and capture output.
154
+ const result = await backend.dispatch(peer, command, request.payload);
155
+ const fullStdout = result.stdout ?? '';
156
+ return {
157
+ peerId: peer.peerId,
158
+ backendKind: peer.backendKind,
159
+ ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
160
+ ...(result.workId !== undefined ? { workId: result.workId } : {}),
161
+ completed: result.workId === undefined,
162
+ stdout: truncate(fullStdout, STDOUT_PREVIEW_LIMIT),
163
+ stderr: truncate(result.stderr ?? '', STDOUT_PREVIEW_LIMIT),
164
+ stdoutDigest: sha256First(fullStdout, 64),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Best-effort teardown: invoke every backend's optional teardown so ephemeral
170
+ * key/credential material (ssh-keys/, cloud-creds/) is swept from disk and
171
+ * does not outlive the daemon. Failures are swallowed — teardown must never
172
+ * throw during surface shutdown.
173
+ */
174
+ async teardown(): Promise<void> {
175
+ await Promise.all(
176
+ [...this.backends.values()].map((backend) =>
177
+ backend.teardown ? backend.teardown().catch(() => {}) : Promise.resolve(),
178
+ ),
179
+ );
180
+ }
181
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Remote handler surface — the host backend for `remote.peers.*`.
3
+ *
4
+ * `remote.peers.invoke` is NOT a catalog method: the SDK publishes it as an HTTP
5
+ * route and injects a `DistributedRuntimeRouteService` (the host's
6
+ * `HostDistributedRuntime`) into `DaemonRemoteRouteContext.distributedRuntime`.
7
+ * This module wires that service together:
8
+ *
9
+ * - a `PeerRegistry` (peer-registry.sqlite; credential fields are
10
+ * goodvibes://secrets/ refs, embedded secrets rejected),
11
+ * - a `RemoteDispatcher` routing by backendKind to the docker/ssh/
12
+ * cloud-terminal/local-process backends,
13
+ * - the SDK `DistributedRuntimeManager` (store: tui/remote/distributed-runtime.json)
14
+ * backing the 16 peer/pairing/work methods,
15
+ * - the `HostDistributedRuntime` service implementing the 17-method contract.
16
+ *
17
+ * The returned registration is plugged into the foundation
18
+ * `DaemonHandlerSurfaceProviders.registerRemote` and surfaced on
19
+ * `DaemonHandlerSurfaces.remoteSurface` + `.remoteDispatch`. Stores init lazily
20
+ * (peer registry init is backgrounded) so wiring stays synchronous.
21
+ */
22
+ import { join } from 'node:path';
23
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
24
+ import type { HandlerContext } from '../context.ts';
25
+ import type {
26
+ RemoteSurfaceRegistration,
27
+ RemoteInvokeAdapter,
28
+ } from '../index.ts';
29
+ import { PeerRegistry } from './peer-registry.ts';
30
+ import { RemoteDispatcher, type RemoteWorkEnqueuer } from './dispatcher.ts';
31
+ import { HostDistributedRuntime } from './service.ts';
32
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../config/surface.ts';
33
+
34
+ type DistributedRuntimeManager = operations.DistributedRuntimeManager;
35
+
36
+ const DISTRIBUTED_RUNTIME_STORE = join(GOODVIBES_DAEMON_SURFACE_ROOT, 'remote', 'distributed-runtime.json');
37
+
38
+ export interface RegisterRemoteSurfaceOptions {
39
+ /**
40
+ * Inject the distributed runtime manager (integration may construct it in
41
+ * services.ts so other runtime bridges can attach to the same instance). When
42
+ * omitted, the surface builds its own manager rooted under the project's
43
+ * .goodvibes directory.
44
+ */
45
+ readonly manager?: DistributedRuntimeManager;
46
+ }
47
+
48
+ /**
49
+ * Build the remote surface. Returns the teardown, the host
50
+ * `DistributedRuntimeRouteService` the SDK facade injects, and the
51
+ * `remote.peers.invoke` dispatch adapter.
52
+ */
53
+ export function registerRemoteSurface(
54
+ ctx: HandlerContext,
55
+ options?: RegisterRemoteSurfaceOptions,
56
+ ): RemoteSurfaceRegistration {
57
+ const registry = new PeerRegistry(ctx.workingDirectory);
58
+
59
+ const manager =
60
+ options?.manager
61
+ ?? new operations.DistributedRuntimeManager(
62
+ join(ctx.workingDirectory, '.goodvibes', DISTRIBUTED_RUNTIME_STORE),
63
+ );
64
+
65
+ // Adapt the SDK manager's work queue to the dispatcher's enqueue hook so a
66
+ // production `remote.peers.invoke {async:true}` creates a work item visible
67
+ // in remote.work.list and returns its id (instead of running synchronously).
68
+ const workEnqueuer: RemoteWorkEnqueuer = {
69
+ enqueue: async (item) => {
70
+ // item.backendKind is intentionally NOT forwarded: the SDK enqueueWork
71
+ // contract has no backendKind parameter, and the work runner re-resolves
72
+ // the backend from the live peer record at claim time (so a peer that is
73
+ // re-registered onto a different backend before the work runs is honored).
74
+ const work = await manager.enqueueWork({
75
+ peerId: item.peerId,
76
+ command: item.command,
77
+ actor: item.queuedBy,
78
+ ...(item.payload !== undefined ? { payload: item.payload } : {}),
79
+ });
80
+ return { workId: work.id };
81
+ },
82
+ };
83
+
84
+ const dispatcher = new RemoteDispatcher({
85
+ registry,
86
+ credentials: ctx.credentials,
87
+ logger: ctx.logger,
88
+ homeDirectory: ctx.homeDirectory,
89
+ workEnqueuer,
90
+ });
91
+
92
+ const service = new HostDistributedRuntime(manager, dispatcher);
93
+
94
+ // Initialize persistent state lazily in the background so surface
95
+ // construction stays synchronous and never blocks daemon bootstrap.
96
+ void registry.init().catch((error) => {
97
+ ctx.logger.error('remote peer registry init failed', { error });
98
+ });
99
+ if (!options?.manager) {
100
+ void manager.start().catch((error) => {
101
+ ctx.logger.error('distributed runtime manager start failed', { error });
102
+ });
103
+ }
104
+
105
+ const dispatch: RemoteInvokeAdapter = {
106
+ invoke: (input: Record<string, unknown>) => service.invokePeer(input),
107
+ };
108
+
109
+ const unregister = (): void => {
110
+ registry.close();
111
+ // Best-effort, fire-and-forget sweep of ephemeral key/credential material
112
+ // (ssh-keys/, cloud-creds/) so no secret-bearing file outlives the surface.
113
+ // Kept off the synchronous teardown path; failures are swallowed inside.
114
+ void dispatcher.teardown().catch((error) => {
115
+ ctx.logger.error('remote backend teardown failed', { error });
116
+ });
117
+ };
118
+
119
+ return { unregister, service, dispatch };
120
+ }