@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,62 @@
|
|
|
1
|
+
import { TriggerManager } from '@pellux/goodvibes-sdk/platform/triggers';
|
|
2
|
+
import { createBunStreamHost, createProcessManagerTriggerHost, createTriggerActionExecutor } from '@pellux/goodvibes-sdk/platform/triggers';
|
|
3
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
4
|
+
import type { AgentManager, ProcessManager } from '@pellux/goodvibes-sdk/platform/tools';
|
|
5
|
+
import type { SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
6
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The trigger family: stream watchers, on-exit process triggers, and condition
|
|
10
|
+
* checks, supervised as one.
|
|
11
|
+
*
|
|
12
|
+
* This daemon composes the full family and feeds the manager to the fleet as
|
|
13
|
+
* its trigger supervisor, so a trigger defined against the daemon fires
|
|
14
|
+
* reliably. The daemon is the right process to own it — it is the one that
|
|
15
|
+
* stays running.
|
|
16
|
+
*
|
|
17
|
+
* Two things about the shape are load-bearing:
|
|
18
|
+
* - `config` is a CLOSURE over the config manager rather than a snapshot, so
|
|
19
|
+
* toggling `watchers.triggers.*` takes effect on the next read instead of at
|
|
20
|
+
* the next restart.
|
|
21
|
+
* - the process host is ProcessManager-backed, so a supervised on-exit child
|
|
22
|
+
* inherits the same credential-environment scrub, live output collection and
|
|
23
|
+
* SIGTERM/SIGKILL watchdog as any other background command the daemon runs.
|
|
24
|
+
*/
|
|
25
|
+
export function createTriggerServices(deps: {
|
|
26
|
+
readonly configManager: ConfigManager;
|
|
27
|
+
readonly shellPaths: ShellPathService;
|
|
28
|
+
readonly surfaceRoot: string;
|
|
29
|
+
readonly agentManager: AgentManager;
|
|
30
|
+
readonly processManager: ProcessManager;
|
|
31
|
+
readonly sessionBroker: Pick<SharedSessionBroker, 'getSession'>;
|
|
32
|
+
}): TriggerManager {
|
|
33
|
+
const { configManager } = deps;
|
|
34
|
+
return new TriggerManager({
|
|
35
|
+
storePath: deps.shellPaths.resolveProjectPath(deps.surfaceRoot, 'triggers.json'),
|
|
36
|
+
config: () => ({
|
|
37
|
+
enabled: configManager.get('watchers.triggers.enabled'),
|
|
38
|
+
backoffLadderMs: configManager.get('watchers.triggers.backoffLadderMs'),
|
|
39
|
+
breakerStrikes: configManager.get('watchers.triggers.breakerStrikes'),
|
|
40
|
+
defaultCheckIntervalMs: configManager.get('watchers.triggers.defaultCheckIntervalMs'),
|
|
41
|
+
probeTimeoutMs: configManager.get('watchers.triggers.probeTimeoutMs'),
|
|
42
|
+
maxConcurrentChecks: configManager.get('watchers.triggers.maxConcurrentChecks'),
|
|
43
|
+
observationRingSize: configManager.get('watchers.triggers.observationRingSize'),
|
|
44
|
+
runHistoryLimit: configManager.get('watchers.triggers.runHistoryLimit'),
|
|
45
|
+
runHistoryTtlHours: configManager.get('watchers.triggers.runHistoryTtlHours'),
|
|
46
|
+
eventLogLimit: configManager.get('watchers.triggers.eventLogLimit'),
|
|
47
|
+
eventLogTtlHours: configManager.get('watchers.triggers.eventLogTtlHours'),
|
|
48
|
+
sweepIntervalMs: configManager.get('watchers.triggers.sweepIntervalMs'),
|
|
49
|
+
supervisionTickMs: configManager.get('watchers.triggers.supervisionTickMs'),
|
|
50
|
+
streamQueueLimit: configManager.get('watchers.triggers.streamQueueLimit'),
|
|
51
|
+
streamBatchLines: configManager.get('watchers.triggers.streamBatchLines'),
|
|
52
|
+
streamBatchIntervalMs: configManager.get('watchers.triggers.streamBatchIntervalMs'),
|
|
53
|
+
onExitMaxDurationMs: configManager.get('watchers.triggers.onExitMaxDurationMs'),
|
|
54
|
+
onExitStdin: configManager.get('watchers.triggers.onExitStdin'),
|
|
55
|
+
outputTailBytes: configManager.get('watchers.triggers.outputTailBytes'),
|
|
56
|
+
}),
|
|
57
|
+
actions: createTriggerActionExecutor({ agents: deps.agentManager, processManager: deps.processManager }),
|
|
58
|
+
processHost: createProcessManagerTriggerHost(deps.processManager),
|
|
59
|
+
streamHost: createBunStreamHost(),
|
|
60
|
+
sessionIsLive: (sessionId: string) => deps.sessionBroker.getSession(sessionId) !== null,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
3
|
+
import {
|
|
4
|
+
normalizeWorkspaceRoot,
|
|
5
|
+
probeWorktreeLink,
|
|
6
|
+
resolveWorkspaceRegistration,
|
|
7
|
+
type DeclinedWorkspaceRecord,
|
|
8
|
+
type RegisteredWorkspaceRecord,
|
|
9
|
+
type WorkspaceCoverageStatus,
|
|
10
|
+
type WorkspaceGitMetadata,
|
|
11
|
+
type WorkspaceResolution,
|
|
12
|
+
} from '@pellux/goodvibes-sdk/platform/workspace';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Checkpoint eligibility, read live off the shared workspace-registration store.
|
|
16
|
+
*
|
|
17
|
+
* The daemon takes automatic checkpoints on turn and agent-run lifecycle events.
|
|
18
|
+
* Which workspaces that is allowed to happen in is the owner's registered-
|
|
19
|
+
* workspaces-only ruling: only a workspace that was EXPLICITLY registered for
|
|
20
|
+
* checkpoints (`checkpointEligible`) qualifies, and a directory somebody merely
|
|
21
|
+
* opened in a surface never silently becomes checkpoint-eligible.
|
|
22
|
+
*
|
|
23
|
+
* This read is synchronous by design. The decision is made per lifecycle event,
|
|
24
|
+
* inside a subscription callback that cannot await, and it has to reflect the
|
|
25
|
+
* store as it is on disk RIGHT NOW — registering a workspace while the daemon is
|
|
26
|
+
* running has to take effect on the next eligible event, not on the next
|
|
27
|
+
* restart. The SDK's resolver is pure, so the only I/O is one small JSON read
|
|
28
|
+
* plus a single git worktree probe amortized at construction.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type StoreShellPaths = Pick<ShellPathService, 'resolveUserPath' | 'homeDirectory'>;
|
|
32
|
+
|
|
33
|
+
/** Path of the shared store's JSON document — the same path the SDK's gateway verb group constructs its own store over. */
|
|
34
|
+
export function sharedWorkspaceRegistrationStorePath(shellPaths: StoreShellPaths): string {
|
|
35
|
+
return shellPaths.resolveUserPath('control-plane', 'workspace-registrations.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readString(value: unknown): string {
|
|
43
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRegisteredRecord(value: unknown): RegisteredWorkspaceRecord | null {
|
|
47
|
+
if (!isRecord(value)) return null;
|
|
48
|
+
const root = readString(value.root);
|
|
49
|
+
const registeredAt = readString(value.registeredAt);
|
|
50
|
+
if (!root || !registeredAt || Number.isNaN(Date.parse(registeredAt))) return null;
|
|
51
|
+
const label = readString(value.label);
|
|
52
|
+
const origin = readString(value.origin);
|
|
53
|
+
return {
|
|
54
|
+
root: normalizeWorkspaceRoot(root),
|
|
55
|
+
registeredAt,
|
|
56
|
+
...(label ? { label } : {}),
|
|
57
|
+
...(origin ? { origin } : {}),
|
|
58
|
+
// Strictly `true` only; any other value (including absent) is not eligible.
|
|
59
|
+
...(value.checkpointEligible === true ? { checkpointEligible: true } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseDeclinedRecord(value: unknown): DeclinedWorkspaceRecord | null {
|
|
64
|
+
if (!isRecord(value)) return null;
|
|
65
|
+
const root = readString(value.root);
|
|
66
|
+
const declinedAt = readString(value.declinedAt);
|
|
67
|
+
if (!root || !declinedAt || Number.isNaN(Date.parse(declinedAt))) return null;
|
|
68
|
+
return { root: normalizeWorkspaceRoot(root), declinedAt };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SharedRegistrationSnapshot {
|
|
72
|
+
readonly workspaces: readonly RegisteredWorkspaceRecord[];
|
|
73
|
+
readonly declines: readonly DeclinedWorkspaceRecord[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Synchronous read of the shared store's on-disk JSON, mirroring the store's own
|
|
78
|
+
* validation exactly (version 1, workspaces[], declines[]). A missing or
|
|
79
|
+
* unparsable file reads as empty — never throws.
|
|
80
|
+
*/
|
|
81
|
+
export function readSharedWorkspaceRegistrationSnapshotSync(shellPaths: StoreShellPaths): SharedRegistrationSnapshot {
|
|
82
|
+
const path = sharedWorkspaceRegistrationStorePath(shellPaths);
|
|
83
|
+
if (!existsSync(path)) return { workspaces: [], declines: [] };
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
86
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.workspaces)) {
|
|
87
|
+
return { workspaces: [], declines: [] };
|
|
88
|
+
}
|
|
89
|
+
const workspaces = parsed.workspaces
|
|
90
|
+
.map(parseRegisteredRecord)
|
|
91
|
+
.filter((entry): entry is RegisteredWorkspaceRecord => entry !== null);
|
|
92
|
+
const declineList = Array.isArray(parsed.declines) ? parsed.declines : [];
|
|
93
|
+
const declines = declineList
|
|
94
|
+
.map(parseDeclinedRecord)
|
|
95
|
+
.filter((entry): entry is DeclinedWorkspaceRecord => entry !== null);
|
|
96
|
+
return { workspaces, declines };
|
|
97
|
+
} catch {
|
|
98
|
+
return { workspaces: [], declines: [] };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve `path` against ONLY the checkpoint-eligible registrations — the
|
|
104
|
+
* boundary the automatic and explicit checkpoint gates consume. Worktree-link
|
|
105
|
+
* inheritance still applies: a linked worktree of a checkpoint-eligible main
|
|
106
|
+
* repository resolves as covered.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveCheckpointEligibilitySync(
|
|
109
|
+
shellPaths: StoreShellPaths,
|
|
110
|
+
path: string,
|
|
111
|
+
git?: WorkspaceGitMetadata,
|
|
112
|
+
): WorkspaceResolution {
|
|
113
|
+
const snapshot = readSharedWorkspaceRegistrationSnapshotSync(shellPaths);
|
|
114
|
+
const eligible = snapshot.workspaces.filter((entry) => entry.checkpointEligible === true);
|
|
115
|
+
const gitMeta = git ?? probeWorktreeLink(path);
|
|
116
|
+
return resolveWorkspaceRegistration({
|
|
117
|
+
path,
|
|
118
|
+
git: gitMeta,
|
|
119
|
+
registrations: eligible,
|
|
120
|
+
declines: snapshot.declines,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a cheap, repeatable live checkpoint-eligibility checker for one fixed
|
|
126
|
+
* workspace root. `probeWorktreeLink` (a `git` subprocess spawn) runs ONCE here,
|
|
127
|
+
* since a long-running process's working directory and its git-worktree
|
|
128
|
+
* relationship do not change mid-launch; every subsequent call only re-reads the
|
|
129
|
+
* shared registration JSON file, which is cheap enough to call on every
|
|
130
|
+
* turn/agent-lifecycle event.
|
|
131
|
+
*/
|
|
132
|
+
export function createWorkspaceRegistrationLiveChecker(
|
|
133
|
+
shellPaths: StoreShellPaths,
|
|
134
|
+
path: string,
|
|
135
|
+
): () => WorkspaceCoverageStatus {
|
|
136
|
+
const git = probeWorktreeLink(path);
|
|
137
|
+
return () => resolveCheckpointEligibilitySync(shellPaths, path, git).status;
|
|
138
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trust-gated-approvals.ts — how a headless daemon asks the workspace trust
|
|
3
|
+
* question, and how the answer reaches the runs it hosts.
|
|
4
|
+
*
|
|
5
|
+
* The terminal app composes the trust gate at the permission machinery's final
|
|
6
|
+
* ask layer and raises the question as a modal on its own screen. The daemon
|
|
7
|
+
* has the same gate — `trustGatedAsk` next door, reading the same
|
|
8
|
+
* `<cwd>/.goodvibes/<surface>/trust.json` the terminal app writes — and no
|
|
9
|
+
* screen to raise anything on. Before this module it therefore
|
|
10
|
+
* did neither: the gate was constructed, never loaded, never consulted, and no
|
|
11
|
+
* hosted run passed through it.
|
|
12
|
+
*
|
|
13
|
+
* Two pieces close that:
|
|
14
|
+
*
|
|
15
|
+
* 1. `createWorkspaceTrustDecisionAsk` raises the trust question as an
|
|
16
|
+
* ordinary approval record. That is the whole point of the approval-raise
|
|
17
|
+
* path: a process with no screen states the question, publishes it on
|
|
18
|
+
* `approval-update`, and whichever surface is attached answers it. Approved
|
|
19
|
+
* means "trusted", denied means "restricted" — a real decision either way,
|
|
20
|
+
* persisted by the gate, and never asked again for this workspace.
|
|
21
|
+
*
|
|
22
|
+
* 2. `trustGatedApprovalRaiser` puts the gate in front of the raiser the
|
|
23
|
+
* permission manager asks through, and loads the persisted decision before
|
|
24
|
+
* consulting it. The load is here rather than in the composition root
|
|
25
|
+
* because `createRuntimeServices` is synchronous: a fire-and-forget load
|
|
26
|
+
* started at composition time can lose the race with the first hosted run,
|
|
27
|
+
* and losing it means re-asking a question the user already answered.
|
|
28
|
+
* `WorkspaceTrustManager.load()` is idempotent, so paying for it on every
|
|
29
|
+
* ask costs one already-resolved promise after the first.
|
|
30
|
+
*
|
|
31
|
+
* What the daemon does NOT do here is decide by default. An untrusted
|
|
32
|
+
* workspace's hosted run neither runs as if trusted nor fails as if refused: it
|
|
33
|
+
* asks. If the ask cannot be answered — nothing attached, or the broker itself
|
|
34
|
+
* failed — the workspace stays undecided and this run is refused, with the
|
|
35
|
+
* reason in the log rather than in a silence. A refusal that records nothing is
|
|
36
|
+
* the failure mode this whole seam exists to remove.
|
|
37
|
+
*/
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
40
|
+
import type { PermissionPromptDecision, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
41
|
+
import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
|
|
42
|
+
const { trustGatedAsk } = operations;
|
|
43
|
+
type WorkspaceTrustLevel = operations.WorkspaceTrustLevel;
|
|
44
|
+
type WorkspaceTrustManager = operations.WorkspaceTrustManager;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The extra fields a raise carries beside the request itself — the attribution
|
|
48
|
+
* routing/metadata a background-agent ask is stamped with before it reaches the
|
|
49
|
+
* broker. The gate only reads `request.category`, so these ride around it.
|
|
50
|
+
*/
|
|
51
|
+
export interface ApprovalRaiseExtras {
|
|
52
|
+
readonly routeId?: string | undefined;
|
|
53
|
+
readonly metadata?: Record<string, unknown> | undefined;
|
|
54
|
+
/** Expiry for a raised ask; the trust question sets one, tool asks do not. */
|
|
55
|
+
readonly timeoutMs?: number | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Raise an ask and wait for the answer. Matches the SDK's ApprovalRaiser seam. */
|
|
59
|
+
export type ApprovalRaise = (
|
|
60
|
+
input: { readonly request: PermissionPromptRequest } & ApprovalRaiseExtras,
|
|
61
|
+
) => Promise<PermissionPromptDecision>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long a raised trust question stays open before it expires.
|
|
65
|
+
*
|
|
66
|
+
* A hosted run blocked on a question nobody is there to answer is a run that
|
|
67
|
+
* never finishes, and an unattended daemon is the normal case, not the edge
|
|
68
|
+
* one. Ten minutes is long enough for someone who is at a surface to see the
|
|
69
|
+
* ask and answer it, and short enough that an unattended run fails with a
|
|
70
|
+
* reason instead of hanging until the process restarts.
|
|
71
|
+
*/
|
|
72
|
+
export const WORKSPACE_TRUST_ASK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
export interface WorkspaceTrustDecisionAskDeps {
|
|
75
|
+
readonly requestApproval: ApprovalRaise;
|
|
76
|
+
/** The workspace the question is about — it names the directory being trusted. */
|
|
77
|
+
readonly workingDirectory: string;
|
|
78
|
+
readonly timeoutMs?: number | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the `requestTrustDecision` callback `trustGatedAsk` calls when a
|
|
83
|
+
* workspace has no decision yet.
|
|
84
|
+
*
|
|
85
|
+
* The question is raised as a `read`-category request on purpose: it is the
|
|
86
|
+
* trust question itself, raised BY the gate, and routing it back through the
|
|
87
|
+
* gate would ask the gate to decide whether it may ask.
|
|
88
|
+
*/
|
|
89
|
+
export function createWorkspaceTrustDecisionAsk(
|
|
90
|
+
deps: WorkspaceTrustDecisionAskDeps,
|
|
91
|
+
): () => Promise<WorkspaceTrustLevel> {
|
|
92
|
+
return async () => {
|
|
93
|
+
const request: PermissionPromptRequest = {
|
|
94
|
+
callId: `workspace-trust-${randomUUID().slice(0, 8)}`,
|
|
95
|
+
tool: 'workspace-trust',
|
|
96
|
+
args: { workspace: deps.workingDirectory },
|
|
97
|
+
category: 'read',
|
|
98
|
+
analysis: {
|
|
99
|
+
classification: 'workspace-trust',
|
|
100
|
+
riskLevel: 'high',
|
|
101
|
+
summary: `Trust the workspace ${deps.workingDirectory}?`,
|
|
102
|
+
reasons: [
|
|
103
|
+
`A run hosted by this daemon wants to write, execute, or delegate in "${deps.workingDirectory}", which has no trust decision recorded.`,
|
|
104
|
+
'Approving marks the workspace trusted and stops this question being asked for it again.',
|
|
105
|
+
'Denying marks it restricted: reads keep working, and writes, commands and delegation are refused there.',
|
|
106
|
+
],
|
|
107
|
+
target: deps.workingDirectory,
|
|
108
|
+
targetKind: 'path',
|
|
109
|
+
blastRadius: 'local',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const decision = await deps.requestApproval({
|
|
114
|
+
request,
|
|
115
|
+
timeoutMs: deps.timeoutMs ?? WORKSPACE_TRUST_ASK_TIMEOUT_MS,
|
|
116
|
+
metadata: { source: 'workspace-trust', workspace: deps.workingDirectory },
|
|
117
|
+
});
|
|
118
|
+
return decision.approved ? 'trusted' : 'restricted';
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Nothing attached, or the broker failed. Say so and leave the workspace
|
|
121
|
+
// undecided — the next run asks again rather than inheriting a decision
|
|
122
|
+
// nobody made.
|
|
123
|
+
logger.warn('Workspace trust question could not be answered; this run is refused and the workspace stays undecided', {
|
|
124
|
+
workspace: deps.workingDirectory,
|
|
125
|
+
error: error instanceof Error ? error.message : String(error),
|
|
126
|
+
});
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The slice of the trust manager this seam uses. */
|
|
133
|
+
export type TrustGateManager =
|
|
134
|
+
& Pick<WorkspaceTrustManager, 'isCategoryAllowed' | 'isDecided' | 'setLevel'>
|
|
135
|
+
& { load(): Promise<void> };
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Wrap an approval raiser with the workspace trust gate.
|
|
139
|
+
*
|
|
140
|
+
* Layering matches the terminal app's: the gate is the outer ask, the broker
|
|
141
|
+
* raise is the inner one, and the permission manager's own layers (mode,
|
|
142
|
+
* policy, session cache, durable rules) still run before either. A trusted
|
|
143
|
+
* workspace is exactly as permissive as it was; a restricted one refuses
|
|
144
|
+
* non-read categories without asking, because that is what the user chose.
|
|
145
|
+
*
|
|
146
|
+
* `routeId`/`metadata` reach the inner raise unchanged. The gate's own
|
|
147
|
+
* signature carries only the request, so the extras are held against the
|
|
148
|
+
* request object for the duration of the call rather than in a shared slot a
|
|
149
|
+
* concurrent ask could overwrite.
|
|
150
|
+
*/
|
|
151
|
+
export function trustGatedApprovalRaiser(
|
|
152
|
+
manager: TrustGateManager,
|
|
153
|
+
raise: ApprovalRaise,
|
|
154
|
+
requestTrustDecision: () => Promise<WorkspaceTrustLevel>,
|
|
155
|
+
): ApprovalRaise {
|
|
156
|
+
const extrasByRequest = new WeakMap<PermissionPromptRequest, ApprovalRaiseExtras>();
|
|
157
|
+
const gated = trustGatedAsk(
|
|
158
|
+
manager,
|
|
159
|
+
(request) => raise({ request, ...(extrasByRequest.get(request) ?? {}) }),
|
|
160
|
+
requestTrustDecision,
|
|
161
|
+
);
|
|
162
|
+
return async (input) => {
|
|
163
|
+
extrasByRequest.set(input.request, { routeId: input.routeId, metadata: input.metadata });
|
|
164
|
+
// Idempotent, and awaited on every ask rather than once at composition:
|
|
165
|
+
// see the module header for why the composition root cannot await it.
|
|
166
|
+
await manager.load();
|
|
167
|
+
return gated(input.request);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `/update`: version comparison, the latest-release-tag
|
|
3
|
+
* redirect lookup, and honest install-kind detection.
|
|
4
|
+
*
|
|
5
|
+
* Version comparison and the release-tag lookup are re-exported from the
|
|
6
|
+
* SDK's canonical update policy module (platform/runtime/self-update), which
|
|
7
|
+
* was hoisted from this file's semantics — one mechanism everywhere.
|
|
8
|
+
* Install-kind detection stays local: it encodes how THIS package is
|
|
9
|
+
* installed (compiled binary vs bun/npm package vs source run) and what
|
|
10
|
+
* command replaces a swap for each kind.
|
|
11
|
+
*
|
|
12
|
+
* The self-update download/verify/swap orchestration that USES these lives
|
|
13
|
+
* in this daemon's own hourly loop (`src/daemon/lifecycle.ts`); this module
|
|
14
|
+
* only decides "is there a newer version" and "can this install be swapped
|
|
15
|
+
* in place". The terminal app's own manual `/update` command solves the same
|
|
16
|
+
* problem for its own users, independently.
|
|
17
|
+
*/
|
|
18
|
+
export {
|
|
19
|
+
compareVersions,
|
|
20
|
+
normalizeVersion,
|
|
21
|
+
parseReleaseTagFromLocation,
|
|
22
|
+
resolveLatestReleaseTag,
|
|
23
|
+
type UpdateFetchLike,
|
|
24
|
+
} from '@pellux/goodvibes-sdk/platform/runtime/self-update';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How this running process was installed, detected honestly from
|
|
28
|
+
* process.execPath rather than assumed:
|
|
29
|
+
* - "binary": a standalone `bun build --compile` executable with no
|
|
30
|
+
* package-manager ancestry — the scripts/install.sh install path.
|
|
31
|
+
* Swappable in place.
|
|
32
|
+
* - "bun-global-package": running the vendored binary shipped inside an
|
|
33
|
+
* npm/bun-managed package install (execPath contains a "node_modules"
|
|
34
|
+
* path segment — true for both `bun add -g` and a local project
|
|
35
|
+
* dependency). Managed by the package manager; swapping the vendored
|
|
36
|
+
* file in place would fight the next `bun add -g` upgrade, so this is
|
|
37
|
+
* never swapped — the user re-runs their package manager instead.
|
|
38
|
+
* - "source": running directly via the `bun` interpreter (`bun run
|
|
39
|
+
* src/main.ts`), not a compiled binary at all.
|
|
40
|
+
*/
|
|
41
|
+
export type InstallKind = 'binary' | 'bun-global-package' | 'source';
|
|
42
|
+
|
|
43
|
+
export function detectInstallKind(execPath: string): InstallKind {
|
|
44
|
+
const segments = execPath.split(/[\\/]/);
|
|
45
|
+
const execName = (segments[segments.length - 1] ?? '').toLowerCase();
|
|
46
|
+
if (execName === 'bun' || execName === 'bun.exe') {
|
|
47
|
+
return 'source';
|
|
48
|
+
}
|
|
49
|
+
if (segments.includes('node_modules')) {
|
|
50
|
+
return 'bun-global-package';
|
|
51
|
+
}
|
|
52
|
+
return 'binary';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The exact command to tell the user to run instead of a swap, for each non-binary install kind. */
|
|
56
|
+
export function fallbackUpdateCommand(kind: Exclude<InstallKind, 'binary'>): string {
|
|
57
|
+
if (kind === 'bun-global-package') {
|
|
58
|
+
return 'bun add -g goodvibes-daemon';
|
|
59
|
+
}
|
|
60
|
+
return 'curl -fsSL https://goodvibes.sh/install.sh | sh';
|
|
61
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
2
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
|
+
import type { RuntimeEventBus, SessionSurface } from '@/runtime/index.ts';
|
|
4
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
5
|
+
import { readCheckpointGuardSettings, readCheckpointRegistrationSetting } from '../config/checkpoint-settings.ts';
|
|
6
|
+
import { createWorkspaceRegistrationLiveChecker, type StoreShellPaths } from './trust/checkpoint-eligibility.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The workspace checkpoint manager, gated on live registration.
|
|
10
|
+
*
|
|
11
|
+
* This daemon's checkpoint manager scopes the checkpoint git store to the
|
|
12
|
+
* surface's own directory AND gates every automatic snapshot on the owner's
|
|
13
|
+
* registered-workspaces-only ruling.
|
|
14
|
+
*
|
|
15
|
+
* The gate is a LIVE re-check rather than a construction-time decision. The
|
|
16
|
+
* manager only subscribes to turn/agent-lifecycle events when it is built with a
|
|
17
|
+
* runtime bus, so building it without one for an unregistered workspace would
|
|
18
|
+
* mean registering that workspace mid-run had no effect until a restart. It is
|
|
19
|
+
* always built WITH the bus and each individual automatic snapshot attempt is
|
|
20
|
+
* refused instead — by overriding this one instance's own `create`, since the
|
|
21
|
+
* manager has no predicate hook and `create` is the single seam both the
|
|
22
|
+
* automatic subscription and every explicit caller pass through.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface WorkspaceCheckpointing {
|
|
26
|
+
/** The manager itself — automatic snapshots gated, explicit creates unrestricted. */
|
|
27
|
+
readonly manager: WorkspaceCheckpointManager;
|
|
28
|
+
/**
|
|
29
|
+
* The narrower surface handed to the `checkpoints.*` gateway verbs: identical
|
|
30
|
+
* except that an explicit create refuses, with an actionable message, when the
|
|
31
|
+
* workspace is not checkpoint-eligible. Reads (list/diff/sessionChanges) and
|
|
32
|
+
* restore stay unrestricted — they operate over checkpoints that may already
|
|
33
|
+
* exist, including from a since-unregistered workspace.
|
|
34
|
+
*/
|
|
35
|
+
readonly gatewayManager: Pick<WorkspaceCheckpointManager, 'list' | 'create' | 'diff' | 'restore' | 'sessionChanges' | 'workspaceRoot'>;
|
|
36
|
+
/** Whether checkpoints are currently permitted for this workspace. Re-reads the store on every call. */
|
|
37
|
+
readonly currentlyAllowed: () => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createWorkspaceCheckpointing(opts: {
|
|
41
|
+
readonly workspaceRoot: string;
|
|
42
|
+
/**
|
|
43
|
+
* The declare-once storage handle. Passing it resolves the checkpoint git
|
|
44
|
+
* store to `surface.checkpointsDir` instead of the unscoped
|
|
45
|
+
* `<workspaceRoot>/.goodvibes/checkpoints` every product using this SDK would
|
|
46
|
+
* otherwise share. The SDK migrates an existing legacy store into the scoped
|
|
47
|
+
* location on first use.
|
|
48
|
+
*/
|
|
49
|
+
readonly surface: SessionSurface;
|
|
50
|
+
readonly runtimeBus: RuntimeEventBus;
|
|
51
|
+
readonly configManager: ConfigManager;
|
|
52
|
+
readonly shellPaths: StoreShellPaths;
|
|
53
|
+
/** Stamps automatic snapshots with the live session id, so a checkpoint made this launch is found by the session-scoped restore lookup. */
|
|
54
|
+
readonly resolveSessionId?: (ctx: { readonly turnId?: string | undefined; readonly agentId?: string | undefined }) => string | undefined;
|
|
55
|
+
}): WorkspaceCheckpointing {
|
|
56
|
+
const registrationStatus = createWorkspaceRegistrationLiveChecker(opts.shellPaths, opts.workspaceRoot);
|
|
57
|
+
const currentlyAllowed = (): boolean =>
|
|
58
|
+
registrationStatus() === 'covered' || readCheckpointRegistrationSetting(opts.configManager) === 'guarded';
|
|
59
|
+
|
|
60
|
+
const manager = new WorkspaceCheckpointManager({
|
|
61
|
+
workspaceRoot: opts.workspaceRoot,
|
|
62
|
+
// Root and retention guards from the owner's `checkpoints.*` settings. These
|
|
63
|
+
// are defense in depth UNDER the registration rule, not a replacement for
|
|
64
|
+
// it: even a registered root can still be refused as too broad or too large.
|
|
65
|
+
...readCheckpointGuardSettings(opts.configManager),
|
|
66
|
+
surface: opts.surface,
|
|
67
|
+
runtimeBus: opts.runtimeBus,
|
|
68
|
+
...(opts.resolveSessionId ? { resolveSessionId: opts.resolveSessionId } : {}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Automatic snapshots ('turn' | 'agent-run', fired by the manager's own bus
|
|
72
|
+
// subscription) resolve to null quietly when the workspace is not eligible —
|
|
73
|
+
// there is no caller to throw to, and the manager already documents a null
|
|
74
|
+
// return as the cheap no-op for an unchanged tree. Explicit ('manual') creates
|
|
75
|
+
// are NOT re-gated here: they go through the gateway surface below, which
|
|
76
|
+
// throws something actionable before ever reaching this method.
|
|
77
|
+
const originalCreate = manager.create.bind(manager);
|
|
78
|
+
manager.create = ((createOpts) => {
|
|
79
|
+
if (createOpts.kind !== 'manual' && !currentlyAllowed()) return Promise.resolve(null);
|
|
80
|
+
return originalCreate(createOpts);
|
|
81
|
+
}) as typeof manager.create;
|
|
82
|
+
|
|
83
|
+
// Eagerly initialize so the automatic-snapshot subscription is live before the
|
|
84
|
+
// first turn completes. If init() rejects, the manager caches that rejection
|
|
85
|
+
// forever and every later call re-throws it — the catch here only prevents an
|
|
86
|
+
// unhandled rejection at startup; the checkpoint verbs report the failure to
|
|
87
|
+
// whoever calls them.
|
|
88
|
+
void manager.init().catch((error: unknown) => {
|
|
89
|
+
logger.warn('WorkspaceCheckpointManager.init failed', { error: error instanceof Error ? error.message : String(error) });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const gatewayManager: WorkspaceCheckpointing['gatewayManager'] = {
|
|
93
|
+
workspaceRoot: manager.workspaceRoot,
|
|
94
|
+
list: manager.list.bind(manager),
|
|
95
|
+
diff: manager.diff.bind(manager),
|
|
96
|
+
restore: manager.restore.bind(manager),
|
|
97
|
+
sessionChanges: manager.sessionChanges.bind(manager),
|
|
98
|
+
create: (createOpts) => {
|
|
99
|
+
if (!currentlyAllowed()) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Checkpoints are off for this workspace: ${opts.workspaceRoot} is not registered. `
|
|
102
|
+
+ 'Register it first, then retry (or set checkpoints.unregisteredWorkspaces to "guarded" '
|
|
103
|
+
+ 'to opt this workspace out of the registration gate).',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// Default the session stamp from the live resolver when the caller omits
|
|
107
|
+
// it — the resolveSessionId hook only auto-stamps automatic snapshots, so
|
|
108
|
+
// without this an explicit checkpoint made this launch would be written
|
|
109
|
+
// unstamped and excluded by the session-scoped restore lookup.
|
|
110
|
+
const sessionId = createOpts.sessionId ?? opts.resolveSessionId?.({});
|
|
111
|
+
return manager.create(sessionId ? { ...createOpts, sessionId } : createOpts);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { manager, gatewayManager, currentlyAllowed };
|
|
116
|
+
}
|