@pellux/goodvibes-tui 0.25.0 → 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 +5 -3
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
- 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/{channels → handlers}/drafts/draft-store.ts +114 -50
- 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/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/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/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- 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 -35
- package/src/version.ts +1 -1
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type DispatchPayload,
|
|
11
11
|
BackendDispatchError,
|
|
12
12
|
resolveTimeout,
|
|
13
|
+
buildRemoteShellCommand,
|
|
13
14
|
} from './types.ts';
|
|
14
15
|
import { runProcess } from './process-runner.ts';
|
|
15
16
|
import { tokenizeCommand } from './local-process.ts';
|
|
@@ -100,9 +101,7 @@ export function createCloudTerminalBackend(ctx: BackendContext): Backend {
|
|
|
100
101
|
'REMOTE_BACKEND_CREDENTIAL_MISSING',
|
|
101
102
|
);
|
|
102
103
|
}
|
|
103
|
-
const remoteCommand = payload?.args
|
|
104
|
-
? `${command} ${payload.args.join(' ')}`
|
|
105
|
-
: command;
|
|
104
|
+
const remoteCommand = buildRemoteShellCommand(command, payload?.args);
|
|
106
105
|
const { args, credEnvKey } = buildArgs(config, remoteCommand);
|
|
107
106
|
const credPath = await writeCredentialFile(peer.peerId, credential);
|
|
108
107
|
const env: Record<string, string> = { ...(payload?.env ?? {}) };
|
|
@@ -133,5 +132,11 @@ export function createCloudTerminalBackend(ctx: BackendContext): Backend {
|
|
|
133
132
|
await rm(credPath, { force: true }).catch(() => {});
|
|
134
133
|
}
|
|
135
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
|
+
},
|
|
136
141
|
};
|
|
137
142
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type DispatchPayload,
|
|
8
8
|
BackendDispatchError,
|
|
9
9
|
resolveTimeout,
|
|
10
|
+
buildRemoteShellCommand,
|
|
10
11
|
} from './types.ts';
|
|
11
12
|
import { runProcess } from './process-runner.ts';
|
|
12
13
|
import { tokenizeCommand } from './local-process.ts';
|
|
@@ -52,9 +53,7 @@ export function createDockerBackend(ctx: BackendContext): Backend {
|
|
|
52
53
|
env.DOCKER_HOST = resolved;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const innerCommand = payload?.args
|
|
56
|
-
? `${command} ${payload.args.join(' ')}`
|
|
57
|
-
: command;
|
|
56
|
+
const innerCommand = buildRemoteShellCommand(command, payload?.args);
|
|
58
57
|
|
|
59
58
|
const args = ['docker', 'exec'];
|
|
60
59
|
if (payload?.stdin !== undefined) args.push('-i');
|
|
@@ -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
|
+
}
|
|
@@ -44,10 +44,9 @@ function getBunSpawn(): BunSpawn {
|
|
|
44
44
|
return globalBun.spawn as unknown as BunSpawn;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!reader) return '';
|
|
47
|
+
async function readStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
48
|
+
if (!stream) return '';
|
|
49
|
+
const reader = stream.getReader();
|
|
51
50
|
const decoder = new TextDecoder();
|
|
52
51
|
let out = '';
|
|
53
52
|
try {
|
|
@@ -57,14 +56,8 @@ async function drainReader(reader: StreamReader | null): Promise<string> {
|
|
|
57
56
|
if (value) out += decoder.decode(value, { stream: true });
|
|
58
57
|
}
|
|
59
58
|
out += decoder.decode();
|
|
60
|
-
} catch {
|
|
61
|
-
// Reader was cancelled (e.g. on timeout) or errored — return what we have.
|
|
62
59
|
} finally {
|
|
63
|
-
|
|
64
|
-
reader.releaseLock();
|
|
65
|
-
} catch {
|
|
66
|
-
// lock already released
|
|
67
|
-
}
|
|
60
|
+
reader.releaseLock();
|
|
68
61
|
}
|
|
69
62
|
return out;
|
|
70
63
|
}
|
|
@@ -73,6 +66,9 @@ async function drainReader(reader: StreamReader | null): Promise<string> {
|
|
|
73
66
|
* Spawn a subprocess and capture its output with a hard timeout. The first
|
|
74
67
|
* element of `args` is the executable. Throws only on spawn failure; non-zero
|
|
75
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.
|
|
76
72
|
*/
|
|
77
73
|
export async function runProcess(options: RunOptions): Promise<RunResult> {
|
|
78
74
|
if (options.args.length === 0) {
|
|
@@ -97,15 +93,6 @@ export async function runProcess(options: RunOptions): Promise<RunResult> {
|
|
|
97
93
|
await child.stdin.end();
|
|
98
94
|
}
|
|
99
95
|
|
|
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
96
|
let timedOut = false;
|
|
110
97
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
111
98
|
const timeoutPromise = new Promise<void>((resolve) => {
|
|
@@ -120,27 +107,16 @@ export async function runProcess(options: RunOptions): Promise<RunResult> {
|
|
|
120
107
|
}, options.timeoutMs);
|
|
121
108
|
});
|
|
122
109
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
]);
|
|
126
118
|
|
|
127
|
-
if (
|
|
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);
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
144
120
|
|
|
145
121
|
return {
|
|
146
122
|
exitCode: typeof exitCode === 'number' ? exitCode : -1,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type DispatchPayload,
|
|
11
11
|
BackendDispatchError,
|
|
12
12
|
resolveTimeout,
|
|
13
|
+
buildRemoteShellCommand,
|
|
13
14
|
} from './types.ts';
|
|
14
15
|
import { runProcess } from './process-runner.ts';
|
|
15
16
|
import { tokenizeCommand } from './local-process.ts';
|
|
@@ -80,9 +81,7 @@ export function createSshBackend(ctx: BackendContext): Backend {
|
|
|
80
81
|
const identity = await ensureIdentity(peer, config);
|
|
81
82
|
const port = config.sshPort ?? 22;
|
|
82
83
|
const target = `${config.sshUser}@${config.sshHost}`;
|
|
83
|
-
const remoteCommand = payload?.args
|
|
84
|
-
? `${command} ${payload.args.join(' ')}`
|
|
85
|
-
: command;
|
|
84
|
+
const remoteCommand = buildRemoteShellCommand(command, payload?.args);
|
|
86
85
|
|
|
87
86
|
const args = [
|
|
88
87
|
'ssh',
|
|
@@ -116,5 +115,11 @@ export function createSshBackend(ctx: BackendContext): Backend {
|
|
|
116
115
|
stderr: result.timedOut ? `${result.stderr}\n[remote] ssh command timed out` : result.stderr,
|
|
117
116
|
};
|
|
118
117
|
},
|
|
118
|
+
async teardown(): Promise<void> {
|
|
119
|
+
// Sweep every pooled key file plus the ssh-keys/ dir so no private-key
|
|
120
|
+
// material (or stale ControlMaster sockets) outlives the daemon process.
|
|
121
|
+
pool.clear();
|
|
122
|
+
await rm(keyDir, { recursive: true, force: true }).catch(() => {});
|
|
123
|
+
},
|
|
119
124
|
};
|
|
120
125
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { DaemonCredentialStore } from '../../
|
|
2
|
-
import type {
|
|
1
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
2
|
+
import type { HandlerLogger } from '../../context.ts';
|
|
3
3
|
import type { PeerRecord } from '../peer-registry.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -28,7 +28,7 @@ export interface BackendDispatchResult {
|
|
|
28
28
|
|
|
29
29
|
export interface BackendContext {
|
|
30
30
|
credentials: DaemonCredentialStore;
|
|
31
|
-
logger:
|
|
31
|
+
logger: HandlerLogger;
|
|
32
32
|
/** Daemon home dir — used for ephemeral key material under a 0700 subdir. */
|
|
33
33
|
homeDirectory: string;
|
|
34
34
|
}
|
|
@@ -48,6 +48,13 @@ export interface Backend {
|
|
|
48
48
|
command: string,
|
|
49
49
|
payload?: DispatchPayload,
|
|
50
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>;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
export const DEFAULT_SYNC_TIMEOUT_MS = 120_000;
|
|
@@ -69,3 +76,22 @@ export class BackendDispatchError extends Error {
|
|
|
69
76
|
this.code = code;
|
|
70
77
|
}
|
|
71
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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { HandlerLogger } from '../context.ts';
|
|
3
|
+
import type { DaemonCredentialStore } from '../credentials.ts';
|
|
3
4
|
import { PeerRegistry, type PeerRecord } from './peer-registry.ts';
|
|
4
5
|
import {
|
|
5
6
|
type Backend,
|
|
@@ -9,10 +10,16 @@ import {
|
|
|
9
10
|
createBackends,
|
|
10
11
|
} from './backends/index.ts';
|
|
11
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
|
+
|
|
12
19
|
// ---------------------------------------------------------------------------
|
|
13
20
|
// Work-item hook — long-running invocations are enqueued as work items visible
|
|
14
|
-
// in remote.work.list. The
|
|
15
|
-
// integrator wires this hook to the
|
|
21
|
+
// in remote.work.list. The dispatcher does not own the distributed runtime; the
|
|
22
|
+
// integrator wires this hook to the DistributedRuntimeManager work queue.
|
|
16
23
|
// ---------------------------------------------------------------------------
|
|
17
24
|
|
|
18
25
|
export interface RemoteWorkItemInput {
|
|
@@ -53,7 +60,7 @@ export interface RemoteInvokeResult {
|
|
|
53
60
|
export interface RemoteDispatcherOptions {
|
|
54
61
|
registry: PeerRegistry;
|
|
55
62
|
credentials: DaemonCredentialStore;
|
|
56
|
-
logger:
|
|
63
|
+
logger: HandlerLogger;
|
|
57
64
|
homeDirectory: string;
|
|
58
65
|
/** Optional hook for enqueuing long-running work items. */
|
|
59
66
|
workEnqueuer?: RemoteWorkEnqueuer;
|
|
@@ -84,7 +91,7 @@ export class RemoteDispatcher {
|
|
|
84
91
|
private readonly registry: PeerRegistry;
|
|
85
92
|
private readonly backends: Map<PeerRecord['backendKind'], Backend>;
|
|
86
93
|
private readonly workEnqueuer?: RemoteWorkEnqueuer;
|
|
87
|
-
private readonly logger:
|
|
94
|
+
private readonly logger: HandlerLogger;
|
|
88
95
|
|
|
89
96
|
constructor(options: RemoteDispatcherOptions) {
|
|
90
97
|
this.registry = options.registry;
|
|
@@ -157,4 +164,18 @@ export class RemoteDispatcher {
|
|
|
157
164
|
stdoutDigest: sha256First(fullStdout, 64),
|
|
158
165
|
};
|
|
159
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
|
+
}
|
|
160
181
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
33
|
+
type DistributedRuntimeManager = operations.DistributedRuntimeManager;
|
|
34
|
+
|
|
35
|
+
const DISTRIBUTED_RUNTIME_STORE = join('tui', 'remote', 'distributed-runtime.json');
|
|
36
|
+
|
|
37
|
+
export interface RegisterRemoteSurfaceOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Inject the distributed runtime manager (integration may construct it in
|
|
40
|
+
* services.ts so other runtime bridges can attach to the same instance). When
|
|
41
|
+
* omitted, the surface builds its own manager rooted under the project's
|
|
42
|
+
* .goodvibes directory.
|
|
43
|
+
*/
|
|
44
|
+
readonly manager?: DistributedRuntimeManager;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the remote surface. Returns the teardown, the host
|
|
49
|
+
* `DistributedRuntimeRouteService` the SDK facade injects, and the
|
|
50
|
+
* `remote.peers.invoke` dispatch adapter.
|
|
51
|
+
*/
|
|
52
|
+
export function registerRemoteSurface(
|
|
53
|
+
ctx: HandlerContext,
|
|
54
|
+
options?: RegisterRemoteSurfaceOptions,
|
|
55
|
+
): RemoteSurfaceRegistration {
|
|
56
|
+
const registry = new PeerRegistry(ctx.workingDirectory);
|
|
57
|
+
|
|
58
|
+
const manager =
|
|
59
|
+
options?.manager
|
|
60
|
+
?? new operations.DistributedRuntimeManager(
|
|
61
|
+
join(ctx.workingDirectory, '.goodvibes', DISTRIBUTED_RUNTIME_STORE),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Adapt the SDK manager's work queue to the dispatcher's enqueue hook so a
|
|
65
|
+
// production `remote.peers.invoke {async:true}` creates a work item visible
|
|
66
|
+
// in remote.work.list and returns its id (instead of running synchronously).
|
|
67
|
+
const workEnqueuer: RemoteWorkEnqueuer = {
|
|
68
|
+
enqueue: async (item) => {
|
|
69
|
+
// item.backendKind is intentionally NOT forwarded: the SDK enqueueWork
|
|
70
|
+
// contract has no backendKind parameter, and the work runner re-resolves
|
|
71
|
+
// the backend from the live peer record at claim time (so a peer that is
|
|
72
|
+
// re-registered onto a different backend before the work runs is honored).
|
|
73
|
+
const work = await manager.enqueueWork({
|
|
74
|
+
peerId: item.peerId,
|
|
75
|
+
command: item.command,
|
|
76
|
+
actor: item.queuedBy,
|
|
77
|
+
...(item.payload !== undefined ? { payload: item.payload } : {}),
|
|
78
|
+
});
|
|
79
|
+
return { workId: work.id };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const dispatcher = new RemoteDispatcher({
|
|
84
|
+
registry,
|
|
85
|
+
credentials: ctx.credentials,
|
|
86
|
+
logger: ctx.logger,
|
|
87
|
+
homeDirectory: ctx.homeDirectory,
|
|
88
|
+
workEnqueuer,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const service = new HostDistributedRuntime(manager, dispatcher);
|
|
92
|
+
|
|
93
|
+
// Initialize persistent state lazily in the background so surface
|
|
94
|
+
// construction stays synchronous and never blocks daemon bootstrap.
|
|
95
|
+
void registry.init().catch((error) => {
|
|
96
|
+
ctx.logger.error('remote peer registry init failed', { error });
|
|
97
|
+
});
|
|
98
|
+
if (!options?.manager) {
|
|
99
|
+
void manager.start().catch((error) => {
|
|
100
|
+
ctx.logger.error('distributed runtime manager start failed', { error });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dispatch: RemoteInvokeAdapter = {
|
|
105
|
+
invoke: (input: Record<string, unknown>) => service.invokePeer(input),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const unregister = (): void => {
|
|
109
|
+
registry.close();
|
|
110
|
+
// Best-effort, fire-and-forget sweep of ephemeral key/credential material
|
|
111
|
+
// (ssh-keys/, cloud-creds/) so no secret-bearing file outlives the surface.
|
|
112
|
+
// Kept off the synchronous teardown path; failures are swallowed inside.
|
|
113
|
+
void dispatcher.teardown().catch((error) => {
|
|
114
|
+
ctx.logger.error('remote backend teardown failed', { error });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return { unregister, service, dispatch };
|
|
119
|
+
}
|