@pellux/goodvibes-tui 0.24.1 → 0.26.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 +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -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/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -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 +125 -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 +119 -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 +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -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/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linchpin helper that binds a typed host handler to an SDK-registered gateway
|
|
3
|
+
* method descriptor BY ID.
|
|
4
|
+
*
|
|
5
|
+
* The SDK's `GatewayMethodCatalog` constructor auto-registers every builtin
|
|
6
|
+
* descriptor (channels.* / email.* / calendar.* / ...) with `handler:undefined`.
|
|
7
|
+
* The host attaches its implementation by looking up that canonical descriptor
|
|
8
|
+
* via `catalog.get(id)` and re-registering it WITH the handler using
|
|
9
|
+
* `{ replace: true }`. Only the handler slot changes; the SDK's id, schema,
|
|
10
|
+
* access, scopes, and dangerous flags are preserved verbatim. No descriptor or
|
|
11
|
+
* schema is ever authored here.
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
GatewayMethodCatalog,
|
|
15
|
+
GatewayMethodInvocation,
|
|
16
|
+
GatewayMethodInvocationContext,
|
|
17
|
+
} from './contracts.ts';
|
|
18
|
+
import { HandlerError, REQUIRE_CONFIRM } from './errors.ts';
|
|
19
|
+
|
|
20
|
+
/** Function returned by every registration; calling it removes the attached handler. */
|
|
21
|
+
export type Unregister = () => void;
|
|
22
|
+
|
|
23
|
+
/** Normalized, host-facing principal/auth context derived from the SDK invocation. */
|
|
24
|
+
export interface HandlerContextEnvelope {
|
|
25
|
+
readonly principalId: string;
|
|
26
|
+
readonly admin: boolean;
|
|
27
|
+
readonly scopes: string[];
|
|
28
|
+
readonly explicitUserRequest: boolean;
|
|
29
|
+
readonly authToken: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Typed, host-facing shape of a single method invocation. */
|
|
33
|
+
export interface HandlerInvocation<TBody> {
|
|
34
|
+
readonly body: TBody;
|
|
35
|
+
readonly query: Record<string, string>;
|
|
36
|
+
readonly context: HandlerContextEnvelope;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A host handler: receives the typed invocation, returns the method result. */
|
|
40
|
+
export type TypedHandler<TBody, TResult> = (
|
|
41
|
+
input: HandlerInvocation<TBody>,
|
|
42
|
+
) => Promise<TResult>;
|
|
43
|
+
|
|
44
|
+
export interface RegisterHandlerOptions {
|
|
45
|
+
/**
|
|
46
|
+
* When true, the wrapper enforces a confirmation gate before the handler
|
|
47
|
+
* runs: `body.confirm === true` AND `context.explicitUserRequest === true`.
|
|
48
|
+
*/
|
|
49
|
+
readonly confirm?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function errorMessage(error: unknown): string {
|
|
53
|
+
if (error instanceof Error) return error.message;
|
|
54
|
+
return String(error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive the host-facing context envelope from the raw SDK invocation context.
|
|
59
|
+
* `explicitUserRequest` is carried in `context.metadata.explicitUserRequest`.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeContext(c: GatewayMethodInvocationContext): HandlerContextEnvelope {
|
|
62
|
+
const metadata = c.metadata ?? {};
|
|
63
|
+
return {
|
|
64
|
+
principalId: c.principalId ?? '',
|
|
65
|
+
admin: c.admin === true,
|
|
66
|
+
scopes: c.scopes ? [...c.scopes] : [],
|
|
67
|
+
explicitUserRequest: metadata.explicitUserRequest === true,
|
|
68
|
+
authToken: c.authToken ?? '',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeQuery(query: GatewayMethodInvocation['query']): Record<string, string> {
|
|
73
|
+
const out: Record<string, string> = {};
|
|
74
|
+
if (!query) return out;
|
|
75
|
+
for (const [key, value] of Object.entries(query)) {
|
|
76
|
+
if (typeof value === 'string') out[key] = value;
|
|
77
|
+
else if (value != null) out[key] = String(value);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Guard a confirmation-gated mutation. Throws `HandlerError(REQUIRE_CONFIRM, 403)`
|
|
84
|
+
* unless the caller both set `body.confirm === true` and the request was an
|
|
85
|
+
* explicit user request.
|
|
86
|
+
*/
|
|
87
|
+
export function assertConfirmed(
|
|
88
|
+
body: unknown,
|
|
89
|
+
ctx: { readonly explicitUserRequest: boolean },
|
|
90
|
+
): void {
|
|
91
|
+
const confirmed =
|
|
92
|
+
typeof body === 'object'
|
|
93
|
+
&& body !== null
|
|
94
|
+
&& (body as { confirm?: unknown }).confirm === true;
|
|
95
|
+
if (!confirmed || ctx.explicitUserRequest !== true) {
|
|
96
|
+
throw new HandlerError('explicit user confirmation required', REQUIRE_CONFIRM, 403);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Attach a typed handler to the SDK-registered descriptor identified by
|
|
102
|
+
* `methodId`. Reuses the descriptor the catalog already holds and re-registers
|
|
103
|
+
* it with the wrapped handler via `{ replace: true }`, flipping the builtin
|
|
104
|
+
* method from HTTP-fallback to in-process execution.
|
|
105
|
+
*/
|
|
106
|
+
export function registerCatalogHandler<TBody, TResult>(
|
|
107
|
+
catalog: GatewayMethodCatalog,
|
|
108
|
+
methodId: string,
|
|
109
|
+
handler: TypedHandler<TBody, TResult>,
|
|
110
|
+
options?: RegisterHandlerOptions,
|
|
111
|
+
): Unregister {
|
|
112
|
+
const descriptor = catalog.get(methodId);
|
|
113
|
+
if (!descriptor) {
|
|
114
|
+
throw new HandlerError(`Unknown gateway method: ${methodId}`, 'UNKNOWN_METHOD', 404);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const wrapped = async (inv: GatewayMethodInvocation): Promise<unknown> => {
|
|
118
|
+
const context = normalizeContext(inv.context);
|
|
119
|
+
const body = inv.body as TBody;
|
|
120
|
+
if (options?.confirm) assertConfirmed(inv.body, context);
|
|
121
|
+
try {
|
|
122
|
+
return await handler({ body, query: normalizeQuery(inv.query), context });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof HandlerError) throw error;
|
|
125
|
+
throw new HandlerError(errorMessage(error), 'HANDLER_FAILED', 500);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return catalog.register(descriptor, wrapped, { replace: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A single id→handler binding for batch registration. */
|
|
133
|
+
export interface CatalogHandlerEntry {
|
|
134
|
+
readonly id: string;
|
|
135
|
+
readonly handler: TypedHandler<unknown, unknown>;
|
|
136
|
+
readonly options?: RegisterHandlerOptions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Register many handlers at once. Returns a single `Unregister` that tears them
|
|
141
|
+
* down in REVERSE registration order (best-effort: a failing teardown never
|
|
142
|
+
* aborts the rest).
|
|
143
|
+
*/
|
|
144
|
+
export function registerCatalogHandlers(
|
|
145
|
+
catalog: GatewayMethodCatalog,
|
|
146
|
+
entries: readonly CatalogHandlerEntry[],
|
|
147
|
+
): Unregister {
|
|
148
|
+
const teardowns: Unregister[] = [];
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
teardowns.push(registerCatalogHandler(catalog, entry.id, entry.handler, entry.options));
|
|
151
|
+
}
|
|
152
|
+
return () => {
|
|
153
|
+
for (let i = teardowns.length - 1; i >= 0; i -= 1) {
|
|
154
|
+
try {
|
|
155
|
+
teardowns[i]!();
|
|
156
|
+
} catch {
|
|
157
|
+
// best-effort teardown; continue unwinding the rest
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
buildRemoteShellCommand,
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
import { runProcess } from './process-runner.ts';
|
|
16
|
+
import { tokenizeCommand } from './local-process.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cloud-terminal backend: executes a command in a managed cloud shell / VM via
|
|
20
|
+
* the provider CLI (gcloud / aws / az). The provider credential is resolved from
|
|
21
|
+
* the daemon credential store and supplied to the CLI via a 0600 credentials
|
|
22
|
+
* file or a provider-specific env var — never as an argv token, never logged.
|
|
23
|
+
*/
|
|
24
|
+
export function createCloudTerminalBackend(ctx: BackendContext): Backend {
|
|
25
|
+
const credDir = join(ctx.homeDirectory, '.goodvibes', 'tui', 'operator', 'cloud-creds');
|
|
26
|
+
|
|
27
|
+
async function writeCredentialFile(peerId: string, value: string): Promise<string> {
|
|
28
|
+
await mkdir(credDir, { recursive: true });
|
|
29
|
+
await chmod(credDir, 0o700).catch(() => {});
|
|
30
|
+
const suffix = randomBytes(4).toString('hex');
|
|
31
|
+
const path = join(credDir, `${peerId}.${suffix}.cred`);
|
|
32
|
+
await writeFile(path, value, { mode: 0o600 });
|
|
33
|
+
await chmod(path, 0o600).catch(() => {});
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildArgs(
|
|
38
|
+
config: CloudTerminalBackendConfig,
|
|
39
|
+
remoteCommand: string,
|
|
40
|
+
): { args: string[]; credEnvKey?: string } {
|
|
41
|
+
switch (config.provider) {
|
|
42
|
+
case 'gcp': {
|
|
43
|
+
// gcloud compute ssh runs `command` on the target Cloud Shell / instance.
|
|
44
|
+
const args = ['gcloud', 'compute', 'ssh'];
|
|
45
|
+
if (config.projectId) args.push('--project', config.projectId);
|
|
46
|
+
if (config.location) args.push('--zone', config.location);
|
|
47
|
+
args.push(config.instance ?? 'cloudshell', '--command', remoteCommand);
|
|
48
|
+
return { args, credEnvKey: 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE' };
|
|
49
|
+
}
|
|
50
|
+
case 'aws': {
|
|
51
|
+
// aws ssm send-command style: run shell command on a managed instance.
|
|
52
|
+
const args = ['aws', 'ssm', 'start-session'];
|
|
53
|
+
if (config.location) args.push('--region', config.location);
|
|
54
|
+
if (config.instance) args.push('--target', config.instance);
|
|
55
|
+
args.push(
|
|
56
|
+
'--document-name', 'AWS-StartInteractiveCommand',
|
|
57
|
+
'--parameters', `command=${remoteCommand}`,
|
|
58
|
+
);
|
|
59
|
+
return { args, credEnvKey: 'AWS_SHARED_CREDENTIALS_FILE' };
|
|
60
|
+
}
|
|
61
|
+
case 'azure': {
|
|
62
|
+
// az vm run-command invoke executes a script on the target VM.
|
|
63
|
+
const args = ['az', 'vm', 'run-command', 'invoke'];
|
|
64
|
+
if (config.projectId) args.push('--resource-group', config.projectId);
|
|
65
|
+
if (config.instance) args.push('--name', config.instance);
|
|
66
|
+
args.push(
|
|
67
|
+
'--command-id', 'RunShellScript',
|
|
68
|
+
'--scripts', remoteCommand,
|
|
69
|
+
);
|
|
70
|
+
return { args, credEnvKey: 'AZURE_AUTH_LOCATION' };
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
throw new BackendDispatchError(
|
|
74
|
+
`Unsupported cloud provider: ${String(config.provider)}`,
|
|
75
|
+
'REMOTE_BACKEND_UNSUPPORTED_PROVIDER',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
kind: 'cloud-terminal',
|
|
82
|
+
async dispatch(
|
|
83
|
+
peer: PeerRecord,
|
|
84
|
+
command: string,
|
|
85
|
+
payload?: DispatchPayload,
|
|
86
|
+
): Promise<BackendDispatchResult> {
|
|
87
|
+
if (peer.backendConfig.kind !== 'cloud-terminal') {
|
|
88
|
+
throw new BackendDispatchError(
|
|
89
|
+
`Peer '${peer.peerId}' is not a cloud-terminal peer.`,
|
|
90
|
+
'REMOTE_BACKEND_KIND_MISMATCH',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const config = peer.backendConfig as { kind: 'cloud-terminal' } & CloudTerminalBackendConfig;
|
|
94
|
+
if (tokenizeCommand(command).length === 0) {
|
|
95
|
+
throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
|
|
96
|
+
}
|
|
97
|
+
const credential = await ctx.credentials.resolveRef(config.credentialRef);
|
|
98
|
+
if (!credential || credential.length === 0) {
|
|
99
|
+
throw new BackendDispatchError(
|
|
100
|
+
`Could not resolve cloud credential for peer '${peer.peerId}'.`,
|
|
101
|
+
'REMOTE_BACKEND_CREDENTIAL_MISSING',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const remoteCommand = buildRemoteShellCommand(command, payload?.args);
|
|
105
|
+
const { args, credEnvKey } = buildArgs(config, remoteCommand);
|
|
106
|
+
const credPath = await writeCredentialFile(peer.peerId, credential);
|
|
107
|
+
const env: Record<string, string> = { ...(payload?.env ?? {}) };
|
|
108
|
+
if (credEnvKey) env[credEnvKey] = credPath;
|
|
109
|
+
|
|
110
|
+
ctx.logger.info('remote cloud-terminal dispatch', {
|
|
111
|
+
peerId: peer.peerId,
|
|
112
|
+
provider: config.provider,
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const result = await runProcess({
|
|
116
|
+
args,
|
|
117
|
+
timeoutMs: resolveTimeout(payload),
|
|
118
|
+
env,
|
|
119
|
+
...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
exitCode: result.timedOut ? 124 : result.exitCode,
|
|
123
|
+
stdout: result.stdout,
|
|
124
|
+
stderr: result.timedOut
|
|
125
|
+
? `${result.stderr}\n[remote] cloud command timed out`
|
|
126
|
+
: result.stderr,
|
|
127
|
+
};
|
|
128
|
+
} finally {
|
|
129
|
+
// The plaintext provider credential file is single-use: remove it as
|
|
130
|
+
// soon as the CLI has run (or failed) so credentials never accumulate
|
|
131
|
+
// on disk under cloud-creds/.
|
|
132
|
+
await rm(credPath, { force: true }).catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
async teardown(): Promise<void> {
|
|
136
|
+
// Per-invocation cred files are already removed in dispatch's finally;
|
|
137
|
+
// sweep the cloud-creds/ dir on teardown to clear any file orphaned by a
|
|
138
|
+
// hard crash mid-dispatch so no provider credential outlives the daemon.
|
|
139
|
+
await rm(credDir, { recursive: true, force: true }).catch(() => {});
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
buildRemoteShellCommand,
|
|
11
|
+
} from './types.ts';
|
|
12
|
+
import { runProcess } from './process-runner.ts';
|
|
13
|
+
import { tokenizeCommand } from './local-process.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Docker backend: runs `docker exec {containerName} {command}` against the local
|
|
17
|
+
* Docker socket (or a configured DOCKER_HOST). The command is executed inside
|
|
18
|
+
* the target container via `sh -c` so shell semantics survive the hop, while the
|
|
19
|
+
* docker argv itself is fully tokenized (no shell on the daemon side).
|
|
20
|
+
*/
|
|
21
|
+
export function createDockerBackend(ctx: BackendContext): Backend {
|
|
22
|
+
return {
|
|
23
|
+
kind: 'docker',
|
|
24
|
+
async dispatch(
|
|
25
|
+
peer: PeerRecord,
|
|
26
|
+
command: string,
|
|
27
|
+
payload?: DispatchPayload,
|
|
28
|
+
): Promise<BackendDispatchResult> {
|
|
29
|
+
if (peer.backendConfig.kind !== 'docker') {
|
|
30
|
+
throw new BackendDispatchError(
|
|
31
|
+
`Peer '${peer.peerId}' is not a docker peer.`,
|
|
32
|
+
'REMOTE_BACKEND_KIND_MISMATCH',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const config = peer.backendConfig as { kind: 'docker' } & DockerBackendConfig;
|
|
36
|
+
if (tokenizeCommand(command).length === 0) {
|
|
37
|
+
throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve DOCKER_HOST. When it is a secret reference, pull the real value
|
|
41
|
+
// from the credential store and pass it via env (never argv).
|
|
42
|
+
const env: Record<string, string> = { ...(payload?.env ?? {}) };
|
|
43
|
+
if (config.dockerHost) {
|
|
44
|
+
const resolved = config.dockerHost.startsWith('goodvibes://')
|
|
45
|
+
? await ctx.credentials.resolveRef(config.dockerHost)
|
|
46
|
+
: config.dockerHost;
|
|
47
|
+
if (!resolved) {
|
|
48
|
+
throw new BackendDispatchError(
|
|
49
|
+
`Could not resolve dockerHost for peer '${peer.peerId}'.`,
|
|
50
|
+
'REMOTE_BACKEND_CREDENTIAL_MISSING',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
env.DOCKER_HOST = resolved;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const innerCommand = buildRemoteShellCommand(command, payload?.args);
|
|
57
|
+
|
|
58
|
+
const args = ['docker', 'exec'];
|
|
59
|
+
if (payload?.stdin !== undefined) args.push('-i');
|
|
60
|
+
args.push(config.containerName, 'sh', '-c', innerCommand);
|
|
61
|
+
|
|
62
|
+
ctx.logger.info('remote docker dispatch', {
|
|
63
|
+
peerId: peer.peerId,
|
|
64
|
+
container: config.containerName,
|
|
65
|
+
});
|
|
66
|
+
const result = await runProcess({
|
|
67
|
+
args,
|
|
68
|
+
timeoutMs: resolveTimeout(payload),
|
|
69
|
+
env,
|
|
70
|
+
...(payload?.stdin !== undefined ? { stdin: payload.stdin } : {}),
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
exitCode: result.timedOut ? 124 : result.exitCode,
|
|
74
|
+
stdout: result.stdout,
|
|
75
|
+
stderr: result.timedOut ? `${result.stderr}\n[remote] docker exec timed out` : result.stderr,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PeerRecord } 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 {
|
|
9
|
+
Backend,
|
|
10
|
+
BackendContext,
|
|
11
|
+
BackendDispatchResult,
|
|
12
|
+
DispatchPayload,
|
|
13
|
+
} from './types.ts';
|
|
14
|
+
export {
|
|
15
|
+
BackendDispatchError,
|
|
16
|
+
DEFAULT_SYNC_TIMEOUT_MS,
|
|
17
|
+
MAX_SYNC_TIMEOUT_MS,
|
|
18
|
+
resolveTimeout,
|
|
19
|
+
} from './types.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the full backend map keyed by backendKind. Each backend resolves its
|
|
23
|
+
* own credentials lazily from the daemon credential store on dispatch; no
|
|
24
|
+
* network or process work happens at construction time.
|
|
25
|
+
*/
|
|
26
|
+
export function createBackends(
|
|
27
|
+
ctx: BackendContext,
|
|
28
|
+
): Map<PeerRecord['backendKind'], Backend> {
|
|
29
|
+
const backends: Backend[] = [
|
|
30
|
+
createLocalProcessBackend(ctx),
|
|
31
|
+
createDockerBackend(ctx),
|
|
32
|
+
createSshBackend(ctx),
|
|
33
|
+
createCloudTerminalBackend(ctx),
|
|
34
|
+
];
|
|
35
|
+
const map = new Map<PeerRecord['backendKind'], Backend>();
|
|
36
|
+
for (const backend of backends) {
|
|
37
|
+
map.set(backend.kind, backend);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
@@ -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,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
|
+
}
|