@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -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/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -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 +126 -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 +120 -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 +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -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/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root for the daemon handler layer — the only module that
|
|
3
|
+
* `src/runtime/services.ts` imports. It assembles every surface (routing,
|
|
4
|
+
* inbox+triage, drafts, calendar, email, remote) onto the SDK gateway catalog
|
|
5
|
+
* and returns a single teardown plus the cross-surface handles the runtime
|
|
6
|
+
* needs (routing resolver, the remote `DistributedRuntimeRouteService` the SDK
|
|
7
|
+
* facade injects, and the `remote.peers.invoke` dispatch adapter).
|
|
8
|
+
*
|
|
9
|
+
* Surfaces are provided by the caller as `SurfaceRegister` functions so this
|
|
10
|
+
* foundation module stays free of import cycles and does not author any SDK
|
|
11
|
+
* descriptor or schema. Registration order is fixed; teardown runs in reverse.
|
|
12
|
+
*/
|
|
13
|
+
import type { DistributedRuntimeRouteService } from './contracts.ts';
|
|
14
|
+
import type { HandlerContext, SurfaceRegister } from './context.ts';
|
|
15
|
+
import type { Unregister } from './register.ts';
|
|
16
|
+
|
|
17
|
+
/** Routing surface handle: teardown plus the resolver other surfaces consume. */
|
|
18
|
+
export interface RoutingRegistration {
|
|
19
|
+
readonly unregister: Unregister;
|
|
20
|
+
/** Resolve an inbound channel target to a profile id (exact → wildcard → null). */
|
|
21
|
+
readonly resolveProfileId: (surfaceKind: string, routeId?: string) => string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Adapter the runtime wires into the published `remote.peers.invoke` route. */
|
|
25
|
+
export interface RemoteInvokeAdapter {
|
|
26
|
+
invoke(input: Record<string, unknown>): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remote surface handle: the host service the SDK facade injects + its teardown. */
|
|
30
|
+
export interface RemoteSurfaceRegistration {
|
|
31
|
+
readonly unregister: Unregister;
|
|
32
|
+
readonly service: DistributedRuntimeRouteService;
|
|
33
|
+
readonly dispatch: RemoteInvokeAdapter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aggregate result returned to the runtime for teardown and cross-surface wiring. */
|
|
37
|
+
export interface DaemonHandlerSurfaces {
|
|
38
|
+
readonly unregister: Unregister;
|
|
39
|
+
readonly routing: RoutingRegistration;
|
|
40
|
+
readonly remoteSurface: { readonly service: DistributedRuntimeRouteService };
|
|
41
|
+
readonly remoteDispatch: RemoteInvokeAdapter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The surface implementations the runtime supplies. Each builds its stores and
|
|
46
|
+
* attaches handlers to the SDK catalog via `registerCatalogHandler(s)`.
|
|
47
|
+
*
|
|
48
|
+
* Order of composition (and reverse teardown) is enforced by
|
|
49
|
+
* `registerDaemonHandlers`: routing → inbox(+triage) → drafts → calendar →
|
|
50
|
+
* email → remote.
|
|
51
|
+
*/
|
|
52
|
+
export interface DaemonHandlerSurfaceProviders {
|
|
53
|
+
/** channels.routing.* — returns the resolver consumed by the inbox surface. */
|
|
54
|
+
readonly registerRouting: (ctx: HandlerContext) => RoutingRegistration;
|
|
55
|
+
/** channels.inbox.list (triage-decorated) + inbox.triage.* (daemon-internal). */
|
|
56
|
+
readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
|
|
57
|
+
/** channels.drafts.* */
|
|
58
|
+
readonly registerDrafts: SurfaceRegister;
|
|
59
|
+
/** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
|
|
60
|
+
readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compose all daemon handler surfaces onto the gateway catalog held by `ctx`.
|
|
65
|
+
* Returns a single teardown (reverse order, best-effort) and the handles the
|
|
66
|
+
* runtime must expose: the routing registration, the remote service the SDK
|
|
67
|
+
* facade injects into `DaemonRemoteRouteContext.distributedRuntime`, and the
|
|
68
|
+
* remote invoke dispatch adapter.
|
|
69
|
+
*/
|
|
70
|
+
export function registerDaemonHandlers(
|
|
71
|
+
ctx: HandlerContext,
|
|
72
|
+
providers: DaemonHandlerSurfaceProviders,
|
|
73
|
+
): DaemonHandlerSurfaces {
|
|
74
|
+
const teardowns: Unregister[] = [];
|
|
75
|
+
|
|
76
|
+
const routing = providers.registerRouting(ctx);
|
|
77
|
+
teardowns.push(routing.unregister);
|
|
78
|
+
|
|
79
|
+
teardowns.push(providers.registerInbox(ctx, routing));
|
|
80
|
+
teardowns.push(providers.registerDrafts(ctx));
|
|
81
|
+
// calendar.* and email.* are NOT registered here any more. Both are served
|
|
82
|
+
// by the SDK (control-plane/routes/{calendar,email}.ts over the platform
|
|
83
|
+
// CalDAV/Google and IMAP/SMTP implementations), registered through
|
|
84
|
+
// registerGatewayVerbGroups in runtime/services.ts. This product used to
|
|
85
|
+
// carry its own handlers for the same descriptor ids and, registering later,
|
|
86
|
+
// won — two implementations behind one path, which is the drift the hoist
|
|
87
|
+
// exists to end.
|
|
88
|
+
|
|
89
|
+
const remote = providers.registerRemote(ctx);
|
|
90
|
+
teardowns.push(remote.unregister);
|
|
91
|
+
|
|
92
|
+
// Idempotent: teardown is reachable from more than one shutdown path now that
|
|
93
|
+
// the runtime disposal scope owns it, and the surfaces underneath are not all
|
|
94
|
+
// safe to unwind twice — the inbox surface closes a SQLite handle, which a
|
|
95
|
+
// second pass would close again inside a floating promise (an unhandled
|
|
96
|
+
// rejection, not a caught one). Running once is also simply the honest
|
|
97
|
+
// meaning of "release these surfaces".
|
|
98
|
+
let torn = false;
|
|
99
|
+
const unregister: Unregister = () => {
|
|
100
|
+
if (torn) return;
|
|
101
|
+
torn = true;
|
|
102
|
+
for (let i = teardowns.length - 1; i >= 0; i -= 1) {
|
|
103
|
+
try {
|
|
104
|
+
teardowns[i]!();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
ctx.logger.warn('daemon handler surface teardown failed', { error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
unregister,
|
|
113
|
+
routing,
|
|
114
|
+
remoteSurface: { service: remote.service },
|
|
115
|
+
remoteDispatch: remote.dispatch,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
* An ABSENT context is accepted and read as the empty one: no principal, no
|
|
62
|
+
* scopes, not admin, nobody claiming a person asked. The type says a context is
|
|
63
|
+
* always there and at runtime it is not always — an in-process invoke that
|
|
64
|
+
* builds the invocation by hand can omit it — and reading `.metadata` off
|
|
65
|
+
* `undefined` turned that into a TypeError thrown out of the handler wrapper
|
|
66
|
+
* instead of the refusal every caller can act on. Defaulting to the least
|
|
67
|
+
* privilege is the only safe reading: it can cost a caller an authorization it
|
|
68
|
+
* never proved, never grant one.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeContext(
|
|
71
|
+
c: GatewayMethodInvocationContext | undefined,
|
|
72
|
+
): HandlerContextEnvelope {
|
|
73
|
+
const context = c ?? ({} as GatewayMethodInvocationContext);
|
|
74
|
+
const metadata = context.metadata ?? {};
|
|
75
|
+
return {
|
|
76
|
+
principalId: context.principalId ?? '',
|
|
77
|
+
admin: context.admin === true,
|
|
78
|
+
scopes: context.scopes ? [...context.scopes] : [],
|
|
79
|
+
explicitUserRequest: metadata.explicitUserRequest === true,
|
|
80
|
+
authToken: context.authToken ?? '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeQuery(query: GatewayMethodInvocation['query']): Record<string, string> {
|
|
85
|
+
const out: Record<string, string> = {};
|
|
86
|
+
if (!query) return out;
|
|
87
|
+
for (const [key, value] of Object.entries(query)) {
|
|
88
|
+
if (typeof value === 'string') out[key] = value;
|
|
89
|
+
else if (value != null) out[key] = String(value);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Guard a confirmation-gated mutation. Throws `HandlerError(REQUIRE_CONFIRM, 403)`
|
|
96
|
+
* unless the caller both set `body.confirm === true` and the request was an
|
|
97
|
+
* explicit user request.
|
|
98
|
+
*/
|
|
99
|
+
export function assertConfirmed(
|
|
100
|
+
body: unknown,
|
|
101
|
+
ctx: { readonly explicitUserRequest: boolean },
|
|
102
|
+
): void {
|
|
103
|
+
const confirmed =
|
|
104
|
+
typeof body === 'object'
|
|
105
|
+
&& body !== null
|
|
106
|
+
&& (body as { confirm?: unknown }).confirm === true;
|
|
107
|
+
if (!confirmed || ctx.explicitUserRequest !== true) {
|
|
108
|
+
throw new HandlerError('explicit user confirmation required', REQUIRE_CONFIRM, 403);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Attach a typed handler to the SDK-registered descriptor identified by
|
|
114
|
+
* `methodId`. Reuses the descriptor the catalog already holds and re-registers
|
|
115
|
+
* it with the wrapped handler via `{ replace: true }`, flipping the builtin
|
|
116
|
+
* method from HTTP-fallback to in-process execution.
|
|
117
|
+
*/
|
|
118
|
+
export function registerCatalogHandler<TBody, TResult>(
|
|
119
|
+
catalog: GatewayMethodCatalog,
|
|
120
|
+
methodId: string,
|
|
121
|
+
handler: TypedHandler<TBody, TResult>,
|
|
122
|
+
options?: RegisterHandlerOptions,
|
|
123
|
+
): Unregister {
|
|
124
|
+
const descriptor = catalog.get(methodId);
|
|
125
|
+
if (!descriptor) {
|
|
126
|
+
// 'METHOD_NOT_FOUND' (not the old locally-coined 'UNKNOWN_METHOD') so this lines
|
|
127
|
+
// up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND — the code the SDK's own
|
|
128
|
+
// uncataloged-method 404 now carries (method-catalog.ts's GatewayMethodCatalog
|
|
129
|
+
// .invoke(), daemon/control-plane.ts's invokeGatewayMethodCall, and daemon-sdk's
|
|
130
|
+
// control-routes.ts getGatewayMethod/invokeGatewayMethod). A literal string, not
|
|
131
|
+
// an import of SDKErrorCodes: the pinned SDK (0.38.0) predates that constant, and
|
|
132
|
+
// matching the value this way needs no SDK version bump to stay aligned.
|
|
133
|
+
throw new HandlerError(`Unknown gateway method: ${methodId}`, 'METHOD_NOT_FOUND', 404);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const wrapped = async (inv: GatewayMethodInvocation): Promise<unknown> => {
|
|
137
|
+
const context = normalizeContext(inv.context);
|
|
138
|
+
const body = inv.body as TBody;
|
|
139
|
+
if (options?.confirm) assertConfirmed(inv.body, context);
|
|
140
|
+
try {
|
|
141
|
+
return await handler({ body, query: normalizeQuery(inv.query), context });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof HandlerError) throw error;
|
|
144
|
+
throw new HandlerError(errorMessage(error), 'HANDLER_FAILED', 500);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return catalog.register(descriptor, wrapped, { replace: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A single id→handler binding for batch registration. */
|
|
152
|
+
export interface CatalogHandlerEntry {
|
|
153
|
+
readonly id: string;
|
|
154
|
+
readonly handler: TypedHandler<unknown, unknown>;
|
|
155
|
+
readonly options?: RegisterHandlerOptions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Register many handlers at once. Returns a single `Unregister` that tears them
|
|
160
|
+
* down in REVERSE registration order (best-effort: a failing teardown never
|
|
161
|
+
* aborts the rest).
|
|
162
|
+
*/
|
|
163
|
+
export function registerCatalogHandlers(
|
|
164
|
+
catalog: GatewayMethodCatalog,
|
|
165
|
+
entries: readonly CatalogHandlerEntry[],
|
|
166
|
+
): Unregister {
|
|
167
|
+
const teardowns: Unregister[] = [];
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
teardowns.push(registerCatalogHandler(catalog, entry.id, entry.handler, entry.options));
|
|
170
|
+
}
|
|
171
|
+
return () => {
|
|
172
|
+
for (let i = teardowns.length - 1; i >= 0; i -= 1) {
|
|
173
|
+
try {
|
|
174
|
+
teardowns[i]!();
|
|
175
|
+
} catch {
|
|
176
|
+
// best-effort teardown; continue unwinding the rest
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cloud-terminal backend: executes a command in a managed cloud shell / VM via
|
|
21
|
+
* the provider CLI (gcloud / aws / az). The provider credential is resolved from
|
|
22
|
+
* the daemon credential store and supplied to the CLI via a 0600 credentials
|
|
23
|
+
* file or a provider-specific env var — never as an argv token, never logged.
|
|
24
|
+
*/
|
|
25
|
+
export function createCloudTerminalBackend(ctx: BackendContext): Backend {
|
|
26
|
+
const credDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'cloud-creds');
|
|
27
|
+
|
|
28
|
+
async function writeCredentialFile(peerId: string, value: string): Promise<string> {
|
|
29
|
+
await mkdir(credDir, { recursive: true });
|
|
30
|
+
await chmod(credDir, 0o700).catch(() => {});
|
|
31
|
+
const suffix = randomBytes(4).toString('hex');
|
|
32
|
+
const path = join(credDir, `${peerId}.${suffix}.cred`);
|
|
33
|
+
await writeFile(path, value, { mode: 0o600 });
|
|
34
|
+
await chmod(path, 0o600).catch(() => {});
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildArgs(
|
|
39
|
+
config: CloudTerminalBackendConfig,
|
|
40
|
+
remoteCommand: string,
|
|
41
|
+
): { args: string[]; credEnvKey?: string } {
|
|
42
|
+
switch (config.provider) {
|
|
43
|
+
case 'gcp': {
|
|
44
|
+
// gcloud compute ssh runs `command` on the target Cloud Shell / instance.
|
|
45
|
+
const args = ['gcloud', 'compute', 'ssh'];
|
|
46
|
+
if (config.projectId) args.push('--project', config.projectId);
|
|
47
|
+
if (config.location) args.push('--zone', config.location);
|
|
48
|
+
args.push(config.instance ?? 'cloudshell', '--command', remoteCommand);
|
|
49
|
+
return { args, credEnvKey: 'CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE' };
|
|
50
|
+
}
|
|
51
|
+
case 'aws': {
|
|
52
|
+
// aws ssm send-command style: run shell command on a managed instance.
|
|
53
|
+
const args = ['aws', 'ssm', 'start-session'];
|
|
54
|
+
if (config.location) args.push('--region', config.location);
|
|
55
|
+
if (config.instance) args.push('--target', config.instance);
|
|
56
|
+
args.push(
|
|
57
|
+
'--document-name', 'AWS-StartInteractiveCommand',
|
|
58
|
+
'--parameters', `command=${remoteCommand}`,
|
|
59
|
+
);
|
|
60
|
+
return { args, credEnvKey: 'AWS_SHARED_CREDENTIALS_FILE' };
|
|
61
|
+
}
|
|
62
|
+
case 'azure': {
|
|
63
|
+
// az vm run-command invoke executes a script on the target VM.
|
|
64
|
+
const args = ['az', 'vm', 'run-command', 'invoke'];
|
|
65
|
+
if (config.projectId) args.push('--resource-group', config.projectId);
|
|
66
|
+
if (config.instance) args.push('--name', config.instance);
|
|
67
|
+
args.push(
|
|
68
|
+
'--command-id', 'RunShellScript',
|
|
69
|
+
'--scripts', remoteCommand,
|
|
70
|
+
);
|
|
71
|
+
return { args, credEnvKey: 'AZURE_AUTH_LOCATION' };
|
|
72
|
+
}
|
|
73
|
+
default:
|
|
74
|
+
throw new BackendDispatchError(
|
|
75
|
+
`Unsupported cloud provider: ${String(config.provider)}`,
|
|
76
|
+
'REMOTE_BACKEND_UNSUPPORTED_PROVIDER',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
kind: 'cloud-terminal',
|
|
83
|
+
async dispatch(
|
|
84
|
+
peer: PeerRecord,
|
|
85
|
+
command: string,
|
|
86
|
+
payload?: DispatchPayload,
|
|
87
|
+
): Promise<BackendDispatchResult> {
|
|
88
|
+
if (peer.backendConfig.kind !== 'cloud-terminal') {
|
|
89
|
+
throw new BackendDispatchError(
|
|
90
|
+
`Peer '${peer.peerId}' is not a cloud-terminal peer.`,
|
|
91
|
+
'REMOTE_BACKEND_KIND_MISMATCH',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const config = peer.backendConfig as { kind: 'cloud-terminal' } & CloudTerminalBackendConfig;
|
|
95
|
+
if (tokenizeCommand(command).length === 0) {
|
|
96
|
+
throw new BackendDispatchError('Empty command.', 'REMOTE_BACKEND_BAD_COMMAND');
|
|
97
|
+
}
|
|
98
|
+
const credential = await ctx.credentials.resolveRef(config.credentialRef);
|
|
99
|
+
if (!credential || credential.length === 0) {
|
|
100
|
+
throw new BackendDispatchError(
|
|
101
|
+
`Could not resolve cloud credential for peer '${peer.peerId}'.`,
|
|
102
|
+
'REMOTE_BACKEND_CREDENTIAL_MISSING',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const remoteCommand = buildRemoteShellCommand(command, payload?.args);
|
|
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
|
+
async teardown(): Promise<void> {
|
|
137
|
+
// Per-invocation cred files are already removed in dispatch's finally;
|
|
138
|
+
// sweep the cloud-creds/ dir on teardown to clear any file orphaned by a
|
|
139
|
+
// hard crash mid-dispatch so no provider credential outlives the daemon.
|
|
140
|
+
await rm(credDir, { recursive: true, force: true }).catch(() => {});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|