@pellux/goodvibes-tui 0.24.1 → 0.25.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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -0,0 +1,137 @@
1
+ import { mkdir, writeFile, chmod, rm } 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 { CloudTerminalBackendConfig } from '../peer-registry.ts';
6
+ import {
7
+ type Backend,
8
+ type BackendContext,
9
+ type BackendDispatchResult,
10
+ type DispatchPayload,
11
+ BackendDispatchError,
12
+ resolveTimeout,
13
+ } from './types.ts';
14
+ import { runProcess } from './process-runner.ts';
15
+ import { tokenizeCommand } from './local-process.ts';
16
+
17
+ /**
18
+ * Cloud-terminal backend: executes a command in a managed cloud shell / VM via
19
+ * the provider CLI (gcloud / aws / az). The provider credential is resolved from
20
+ * the daemon credential store and supplied to the CLI via a 0600 credentials
21
+ * file or a provider-specific env var — never as an argv token, never logged.
22
+ */
23
+ export function createCloudTerminalBackend(ctx: BackendContext): Backend {
24
+ const credDir = join(ctx.homeDirectory, '.goodvibes', 'tui', 'operator', 'cloud-creds');
25
+
26
+ async function writeCredentialFile(peerId: string, value: string): Promise<string> {
27
+ await mkdir(credDir, { recursive: true });
28
+ await chmod(credDir, 0o700).catch(() => {});
29
+ const suffix = randomBytes(4).toString('hex');
30
+ const path = join(credDir, `${peerId}.${suffix}.cred`);
31
+ await writeFile(path, value, { mode: 0o600 });
32
+ await chmod(path, 0o600).catch(() => {});
33
+ return path;
34
+ }
35
+
36
+ function buildArgs(
37
+ config: CloudTerminalBackendConfig,
38
+ remoteCommand: string,
39
+ ): { args: string[]; credEnvKey?: string } {
40
+ switch (config.provider) {
41
+ case 'gcp': {
42
+ // gcloud compute ssh runs `command` on the target Cloud Shell / instance.
43
+ const args = ['gcloud', 'compute', 'ssh'];
44
+ if (config.projectId) args.push('--project', config.projectId);
45
+ if (config.location) args.push('--zone', config.location);
46
+ args.push(config.instance ?? 'cloudshell', '--command', remoteCommand);
47
+ return { args, credEnvKey: 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE' };
48
+ }
49
+ case 'aws': {
50
+ // aws ssm send-command style: run shell command on a managed instance.
51
+ const args = ['aws', 'ssm', 'start-session'];
52
+ if (config.location) args.push('--region', config.location);
53
+ if (config.instance) args.push('--target', config.instance);
54
+ args.push(
55
+ '--document-name', 'AWS-StartInteractiveCommand',
56
+ '--parameters', `command=${remoteCommand}`,
57
+ );
58
+ return { args, credEnvKey: 'AWS_SHARED_CREDENTIALS_FILE' };
59
+ }
60
+ case 'azure': {
61
+ // az vm run-command invoke executes a script on the target VM.
62
+ const args = ['az', 'vm', 'run-command', 'invoke'];
63
+ if (config.projectId) args.push('--resource-group', config.projectId);
64
+ if (config.instance) args.push('--name', config.instance);
65
+ args.push(
66
+ '--command-id', 'RunShellScript',
67
+ '--scripts', remoteCommand,
68
+ );
69
+ return { args, credEnvKey: 'AZURE_AUTH_LOCATION' };
70
+ }
71
+ default:
72
+ throw new BackendDispatchError(
73
+ `Unsupported cloud provider: ${String(config.provider)}`,
74
+ 'REMOTE_BACKEND_UNSUPPORTED_PROVIDER',
75
+ );
76
+ }
77
+ }
78
+
79
+ return {
80
+ kind: 'cloud-terminal',
81
+ async dispatch(
82
+ peer: PeerRecord,
83
+ command: string,
84
+ payload?: DispatchPayload,
85
+ ): Promise<BackendDispatchResult> {
86
+ if (peer.backendConfig.kind !== 'cloud-terminal') {
87
+ throw new BackendDispatchError(
88
+ `Peer '${peer.peerId}' is not a cloud-terminal peer.`,
89
+ 'REMOTE_BACKEND_KIND_MISMATCH',
90
+ );
91
+ }
92
+ const config = peer.backendConfig as { kind: 'cloud-terminal' } & CloudTerminalBackendConfig;
93
+ if (tokenizeCommand(command).length === 0) {
94
+ throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
95
+ }
96
+ const credential = await ctx.credentials.resolveRef(config.credentialRef);
97
+ if (!credential || credential.length === 0) {
98
+ throw new BackendDispatchError(
99
+ `Could not resolve cloud credential for peer '${peer.peerId}'.`,
100
+ 'REMOTE_BACKEND_CREDENTIAL_MISSING',
101
+ );
102
+ }
103
+ const remoteCommand = payload?.args && payload.args.length > 0
104
+ ? `${command} ${payload.args.join(' ')}`
105
+ : command;
106
+ const { args, credEnvKey } = buildArgs(config, remoteCommand);
107
+ const credPath = await writeCredentialFile(peer.peerId, credential);
108
+ const env: Record<string, string> = { ...(payload?.env ?? {}) };
109
+ if (credEnvKey) env[credEnvKey] = credPath;
110
+
111
+ ctx.logger.info('remote cloud-terminal dispatch', {
112
+ peerId: peer.peerId,
113
+ provider: config.provider,
114
+ });
115
+ try {
116
+ const result = await runProcess({
117
+ args,
118
+ timeoutMs: resolveTimeout(payload),
119
+ env,
120
+ ...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
121
+ });
122
+ return {
123
+ exitCode: result.timedOut ? 124 : result.exitCode,
124
+ stdout: result.stdout,
125
+ stderr: result.timedOut
126
+ ? `${result.stderr}\n[remote] cloud command timed out`
127
+ : result.stderr,
128
+ };
129
+ } finally {
130
+ // The plaintext provider credential file is single-use: remove it as
131
+ // soon as the CLI has run (or failed) so credentials never accumulate
132
+ // on disk under cloud-creds/.
133
+ await rm(credPath, { force: true }).catch(() => {});
134
+ }
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,80 @@
1
+ import type { PeerRecord } from '../peer-registry.ts';
2
+ import type { DockerBackendConfig } from '../peer-registry.ts';
3
+ import {
4
+ type Backend,
5
+ type BackendContext,
6
+ type BackendDispatchResult,
7
+ type DispatchPayload,
8
+ BackendDispatchError,
9
+ resolveTimeout,
10
+ } from './types.ts';
11
+ import { runProcess } from './process-runner.ts';
12
+ import { tokenizeCommand } from './local-process.ts';
13
+
14
+ /**
15
+ * Docker backend: runs `docker exec {containerName} {command}` against the local
16
+ * Docker socket (or a configured DOCKER_HOST). The command is executed inside
17
+ * the target container via `sh -c` so shell semantics survive the hop, while the
18
+ * docker argv itself is fully tokenized (no shell on the daemon side).
19
+ */
20
+ export function createDockerBackend(ctx: BackendContext): Backend {
21
+ return {
22
+ kind: 'docker',
23
+ async dispatch(
24
+ peer: PeerRecord,
25
+ command: string,
26
+ payload?: DispatchPayload,
27
+ ): Promise<BackendDispatchResult> {
28
+ if (peer.backendConfig.kind !== 'docker') {
29
+ throw new BackendDispatchError(
30
+ `Peer '${peer.peerId}' is not a docker peer.`,
31
+ 'REMOTE_BACKEND_KIND_MISMATCH',
32
+ );
33
+ }
34
+ const config = peer.backendConfig as { kind: 'docker' } & DockerBackendConfig;
35
+ if (tokenizeCommand(command).length === 0) {
36
+ throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
37
+ }
38
+
39
+ // Resolve DOCKER_HOST. When it is a secret reference, pull the real value
40
+ // from the credential store and pass it via env (never argv).
41
+ const env: Record<string, string> = { ...(payload?.env ?? {}) };
42
+ if (config.dockerHost) {
43
+ const resolved = config.dockerHost.startsWith('goodvibes://')
44
+ ? await ctx.credentials.resolveRef(config.dockerHost)
45
+ : config.dockerHost;
46
+ if (!resolved) {
47
+ throw new BackendDispatchError(
48
+ `Could not resolve dockerHost for peer '${peer.peerId}'.`,
49
+ 'REMOTE_BACKEND_CREDENTIAL_MISSING',
50
+ );
51
+ }
52
+ env.DOCKER_HOST = resolved;
53
+ }
54
+
55
+ const innerCommand = payload?.args && payload.args.length > 0
56
+ ? `${command} ${payload.args.join(' ')}`
57
+ : command;
58
+
59
+ const args = ['docker', 'exec'];
60
+ if (payload?.stdin !== undefined) args.push('-i');
61
+ args.push(config.containerName, 'sh', '-c', innerCommand);
62
+
63
+ ctx.logger.info('remote docker dispatch', {
64
+ peerId: peer.peerId,
65
+ container: config.containerName,
66
+ });
67
+ const result = await runProcess({
68
+ args,
69
+ timeoutMs: resolveTimeout(payload),
70
+ env,
71
+ ...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
72
+ });
73
+ return {
74
+ exitCode: result.timedOut ? 124 : result.exitCode,
75
+ stdout: result.stdout,
76
+ stderr: result.timedOut ? `${result.stderr}\n[remote] docker exec timed out` : result.stderr,
77
+ };
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,34 @@
1
+ import type { BackendKind } from '../peer-registry.ts';
2
+ import { type Backend, type BackendContext } from './types.ts';
3
+ import { createLocalProcessBackend } from './local-process.ts';
4
+ import { createDockerBackend } from './docker.ts';
5
+ import { createSshBackend } from './ssh.ts';
6
+ import { createCloudTerminalBackend } from './cloud-terminal.ts';
7
+
8
+ export type { Backend, BackendContext, BackendDispatchResult, DispatchPayload } from './types.ts';
9
+ export { BackendDispatchError, DEFAULT_SYNC_TIMEOUT_MS, MAX_SYNC_TIMEOUT_MS, resolveTimeout } from './types.ts';
10
+ export { createLocalProcessBackend } from './local-process.ts';
11
+ export { createDockerBackend } from './docker.ts';
12
+ export { createSshBackend } from './ssh.ts';
13
+ export { createCloudTerminalBackend } from './cloud-terminal.ts';
14
+ export { tokenizeCommand } from './local-process.ts';
15
+ export { runProcess } from './process-runner.ts';
16
+ export type { RunOptions, RunResult } from './process-runner.ts';
17
+
18
+ /**
19
+ * Build the full backend registry keyed by backendKind. The dispatcher selects
20
+ * a backend by the peer's backendKind.
21
+ */
22
+ export function createBackends(ctx: BackendContext): Map<BackendKind, Backend> {
23
+ const backends: Backend[] = [
24
+ createLocalProcessBackend(ctx),
25
+ createDockerBackend(ctx),
26
+ createSshBackend(ctx),
27
+ createCloudTerminalBackend(ctx),
28
+ ];
29
+ const registry = new Map<BackendKind, Backend>();
30
+ for (const backend of backends) {
31
+ registry.set(backend.kind, backend);
32
+ }
33
+ return registry;
34
+ }
@@ -0,0 +1,113 @@
1
+ import type { PeerRecord } from '../peer-registry.ts';
2
+ import type { LocalProcessBackendConfig } from '../peer-registry.ts';
3
+ import {
4
+ type Backend,
5
+ type BackendContext,
6
+ type BackendDispatchResult,
7
+ type DispatchPayload,
8
+ BackendDispatchError,
9
+ resolveTimeout,
10
+ } from './types.ts';
11
+ import { runProcess } from './process-runner.ts';
12
+
13
+ /** Split a command string into argv, honoring single/double quotes. */
14
+ export function tokenizeCommand(command: string): string[] {
15
+ const tokens: string[] = [];
16
+ let current = '';
17
+ let quote: '"' | "'" | null = null;
18
+ let escaped = false;
19
+ let started = false;
20
+ for (const ch of command) {
21
+ if (escaped) {
22
+ current += ch;
23
+ escaped = false;
24
+ started = true;
25
+ continue;
26
+ }
27
+ if (ch === '\\' && quote !== "'") {
28
+ escaped = true;
29
+ started = true;
30
+ continue;
31
+ }
32
+ if (quote) {
33
+ if (ch === quote) {
34
+ quote = null;
35
+ } else {
36
+ current += ch;
37
+ }
38
+ continue;
39
+ }
40
+ if (ch === '"' || ch === "'") {
41
+ quote = ch;
42
+ started = true;
43
+ continue;
44
+ }
45
+ if (ch === ' ' || ch === '\t' || ch === '\n') {
46
+ if (started) {
47
+ tokens.push(current);
48
+ current = '';
49
+ started = false;
50
+ }
51
+ continue;
52
+ }
53
+ current += ch;
54
+ started = true;
55
+ }
56
+ if (started) tokens.push(current);
57
+ if (quote) {
58
+ throw new BackendDispatchError('Unterminated quote in command string.', 'REMOTE_BACKEND_BAD_COMMAND');
59
+ }
60
+ return tokens;
61
+ }
62
+
63
+ /**
64
+ * Local-process backend: spawns the command directly on the daemon host. This is
65
+ * the lowest-friction backend, used for self-hosted peers. An optional
66
+ * allowlist on backendConfig restricts which executables may run.
67
+ */
68
+ export function createLocalProcessBackend(ctx: BackendContext): Backend {
69
+ return {
70
+ kind: 'local-process',
71
+ async dispatch(
72
+ peer: PeerRecord,
73
+ command: string,
74
+ payload?: DispatchPayload,
75
+ ): Promise<BackendDispatchResult> {
76
+ if (peer.backendConfig.kind !== 'local-process') {
77
+ throw new BackendDispatchError(
78
+ `Peer '${peer.peerId}' is not a local-process peer.`,
79
+ 'REMOTE_BACKEND_KIND_MISMATCH',
80
+ );
81
+ }
82
+ const config = peer.backendConfig as { kind: 'local-process' } & LocalProcessBackendConfig;
83
+ const tokens = tokenizeCommand(command);
84
+ if (tokens.length === 0) {
85
+ throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
86
+ }
87
+ const executable = tokens[0]!;
88
+ if (config.allowedCommands && config.allowedCommands.length > 0) {
89
+ if (!config.allowedCommands.includes(executable)) {
90
+ throw new BackendDispatchError(
91
+ `Command '${executable}' is not in the peer allowlist.`,
92
+ 'REMOTE_BACKEND_COMMAND_DENIED',
93
+ );
94
+ }
95
+ }
96
+ const args = [...tokens, ...(payload?.args ?? [])];
97
+ const cwd = payload?.cwd ?? config.cwd;
98
+ ctx.logger.info('remote local-process dispatch', { peerId: peer.peerId, executable });
99
+ const result = await runProcess({
100
+ args,
101
+ timeoutMs: resolveTimeout(payload),
102
+ ...(cwd !== undefined ? { cwd } : {}),
103
+ ...(payload?.env !== undefined ? { env: payload.env } : {}),
104
+ ...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
105
+ });
106
+ return {
107
+ exitCode: result.timedOut ? 124 : result.exitCode,
108
+ stdout: result.stdout,
109
+ stderr: result.timedOut ? `${result.stderr}\n[remote] command timed out` : result.stderr,
110
+ };
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,151 @@
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
+ type StreamReader = ReadableStreamDefaultReader<Uint8Array>;
48
+
49
+ async function drainReader(reader: StreamReader | null): Promise<string> {
50
+ if (!reader) return '';
51
+ const decoder = new TextDecoder();
52
+ let out = '';
53
+ try {
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ if (value) out += decoder.decode(value, { stream: true });
58
+ }
59
+ out += decoder.decode();
60
+ } catch {
61
+ // Reader was cancelled (e.g. on timeout) or errored — return what we have.
62
+ } finally {
63
+ try {
64
+ reader.releaseLock();
65
+ } catch {
66
+ // lock already released
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Spawn a subprocess and capture its output with a hard timeout. The first
74
+ * element of `args` is the executable. Throws only on spawn failure; non-zero
75
+ * exit codes are returned in the result.
76
+ */
77
+ export async function runProcess(options: RunOptions): Promise<RunResult> {
78
+ if (options.args.length === 0) {
79
+ throw new Error('runProcess requires at least one argument (the executable).');
80
+ }
81
+ const spawn = getBunSpawn();
82
+ const mergedEnv: Record<string, string | undefined> = {
83
+ ...process.env,
84
+ ...(options.env ?? {}),
85
+ };
86
+
87
+ const child = spawn(options.args, {
88
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
89
+ env: mergedEnv,
90
+ stdin: options.stdin !== undefined ? 'pipe' : 'ignore',
91
+ stdout: 'pipe',
92
+ stderr: 'pipe',
93
+ });
94
+
95
+ if (options.stdin !== undefined && child.stdin) {
96
+ child.stdin.write(options.stdin);
97
+ await child.stdin.end();
98
+ }
99
+
100
+ // Begin draining stdout/stderr immediately, holding the readers so we can
101
+ // cancel them on timeout. Otherwise a killed shell whose grandchild (e.g.
102
+ // `sh -c 'sleep 5'`) inherited the stdout pipe keeps the stream open until
103
+ // that orphan exits, blocking us for the child's full lifetime.
104
+ const stdoutReader = child.stdout ? child.stdout.getReader() : null;
105
+ const stderrReader = child.stderr ? child.stderr.getReader() : null;
106
+ const stdoutPromise = drainReader(stdoutReader);
107
+ const stderrPromise = drainReader(stderrReader);
108
+
109
+ let timedOut = false;
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ const timeoutPromise = new Promise<void>((resolve) => {
112
+ timer = setTimeout(() => {
113
+ timedOut = true;
114
+ try {
115
+ child.kill('SIGKILL');
116
+ } catch {
117
+ // process may have already exited
118
+ }
119
+ resolve();
120
+ }, options.timeoutMs);
121
+ });
122
+
123
+ // Resolve as soon as the process exits OR the timeout fires.
124
+ await Promise.race([child.exited, timeoutPromise]);
125
+ if (timer) clearTimeout(timer);
126
+
127
+ if (timedOut) {
128
+ // Unblock the drains in case an orphaned grandchild still holds the pipe.
129
+ try {
130
+ await stdoutReader?.cancel();
131
+ } catch {
132
+ // already closed
133
+ }
134
+ try {
135
+ await stderrReader?.cancel();
136
+ } catch {
137
+ // already closed
138
+ }
139
+ }
140
+
141
+ const stdout = await stdoutPromise;
142
+ const stderr = await stderrPromise;
143
+ const exitCode = await child.exited.catch(() => -1);
144
+
145
+ return {
146
+ exitCode: typeof exitCode === 'number' ? exitCode : -1,
147
+ stdout,
148
+ stderr,
149
+ timedOut,
150
+ };
151
+ }
@@ -0,0 +1,120 @@
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
+ } from './types.ts';
14
+ import { runProcess } from './process-runner.ts';
15
+ import { tokenizeCommand } from './local-process.ts';
16
+
17
+ /**
18
+ * Persistent-key material is written to {homeDirectory}/.goodvibes/tui/operator/
19
+ * ssh-keys/{peerId}.key with 0600 permissions and reused across invocations
20
+ * (connection pooling via the OpenSSH ControlMaster multiplexer). The key value
21
+ * itself comes only from the daemon credential store — never argv, never logs.
22
+ */
23
+ interface PooledIdentity {
24
+ keyPath: string;
25
+ controlPath: string;
26
+ identityRef: string;
27
+ }
28
+
29
+ export function createSshBackend(ctx: BackendContext): Backend {
30
+ const pool = new Map<string, PooledIdentity>();
31
+ const keyDir = join(ctx.homeDirectory, '.goodvibes', 'tui', 'operator', 'ssh-keys');
32
+
33
+ async function ensureIdentity(
34
+ peer: PeerRecord,
35
+ config: SshBackendConfig,
36
+ ): Promise<PooledIdentity> {
37
+ const existing = pool.get(peer.peerId);
38
+ if (existing && existing.identityRef === config.identityRef) {
39
+ return existing;
40
+ }
41
+ const key = await ctx.credentials.resolveRef(config.identityRef);
42
+ if (!key || key.length === 0) {
43
+ throw new BackendDispatchError(
44
+ `Could not resolve SSH identity for peer '${peer.peerId}'.`,
45
+ 'REMOTE_BACKEND_CREDENTIAL_MISSING',
46
+ );
47
+ }
48
+ await mkdir(keyDir, { recursive: true });
49
+ await chmod(keyDir, 0o700).catch(() => {});
50
+ const suffix = randomBytes(4).toString('hex');
51
+ const keyPath = join(keyDir, `${peer.peerId}.${suffix}.key`);
52
+ const normalizedKey = key.endsWith('\n') ? key : `${key}\n`;
53
+ await writeFile(keyPath, normalizedKey, { mode: 0o600 });
54
+ await chmod(keyPath, 0o600).catch(() => {});
55
+ const controlPath = join(keyDir, `${peer.peerId}.${suffix}.ctl`);
56
+ const identity: PooledIdentity = { keyPath, controlPath, identityRef: config.identityRef };
57
+ // Replace any prior identity for this peer and clean up its key file.
58
+ if (existing) await rm(existing.keyPath, { force: true }).catch(() => {});
59
+ pool.set(peer.peerId, identity);
60
+ return identity;
61
+ }
62
+
63
+ return {
64
+ kind: 'ssh',
65
+ async dispatch(
66
+ peer: PeerRecord,
67
+ command: string,
68
+ payload?: DispatchPayload,
69
+ ): Promise<BackendDispatchResult> {
70
+ if (peer.backendConfig.kind !== 'ssh') {
71
+ throw new BackendDispatchError(
72
+ `Peer '${peer.peerId}' is not an ssh peer.`,
73
+ 'REMOTE_BACKEND_KIND_MISMATCH',
74
+ );
75
+ }
76
+ const config = peer.backendConfig as { kind: 'ssh' } & SshBackendConfig;
77
+ if (tokenizeCommand(command).length === 0) {
78
+ throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
79
+ }
80
+ const identity = await ensureIdentity(peer, config);
81
+ const port = config.sshPort ?? 22;
82
+ const target = `${config.sshUser}@${config.sshHost}`;
83
+ const remoteCommand = payload?.args && payload.args.length > 0
84
+ ? `${command} ${payload.args.join(' ')}`
85
+ : command;
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
+ };
120
+ }