@hellolynk-ai/workbuddy-mcp 0.0.0-stage → 0.3.97
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/LICENSE +92 -0
- package/README.md +103 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/clients/core/hellolynk-client-account.mjs +180 -0
- package/clients/core/hellolynk-client-core.mjs +770 -0
- package/clients/core/hellolynk-client-profile.mjs +191 -0
- package/clients/core/hellolynk-client-update-entry.mjs +31 -0
- package/clients/core/hellolynk-client-update-runtime.mjs +600 -0
- package/clients/core/hellolynk-client-update.mjs +453 -0
- package/clients/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/clients/core/hellolynk-device-enrollment.mjs +435 -0
- package/clients/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/clients/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/clients/core/hellolynk-host-auth.mjs +347 -0
- package/clients/core/hellolynk-host-protected-store.mjs +2721 -0
- package/clients/core/hellolynk-host-transport-client.mjs +946 -0
- package/clients/core/hellolynk-host.mjs +2374 -0
- package/clients/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/clients/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/clients/core/hellolynk-inbox-read-state.mjs +11 -0
- package/clients/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/clients/core/hellolynk-instance-conversation.mjs +185 -0
- package/clients/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/clients/core/hellolynk-local-contacts.mjs +1228 -0
- package/clients/core/hellolynk-local-data-migration.mjs +766 -0
- package/clients/core/hellolynk-local-data-protection.mjs +747 -0
- package/clients/core/hellolynk-local-identity-trust.mjs +274 -0
- package/clients/core/hellolynk-local-messaging.mjs +3043 -0
- package/clients/core/hellolynk-local-peer-transport.mjs +103 -0
- package/clients/core/hellolynk-local-process-lock.mjs +157 -0
- package/clients/core/hellolynk-model-inbox.mjs +230 -0
- package/clients/core/hellolynk-model-notices.cjs +60 -0
- package/clients/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/clients/core/hellolynk-native-conversations.mjs +280 -0
- package/clients/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/clients/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/clients/core/hellolynk-native-notice.mjs +50 -0
- package/clients/core/hellolynk-native-reply-routes.mjs +96 -0
- package/clients/core/hellolynk-native-task-index.cjs +305 -0
- package/clients/core/hellolynk-native-worker-lease.mjs +84 -0
- package/clients/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/clients/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/clients/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/clients/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/clients/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/clients/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/clients/core/hellolynk-platform-credential-store.mjs +773 -0
- package/clients/core/hellolynk-relay-client-cli.mjs +232 -0
- package/clients/core/hellolynk-release-retention.mjs +277 -0
- package/clients/core/hellolynk-runtime-safety.cjs +471 -0
- package/clients/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/clients/core/hellolynk-workbench-connection.mjs +234 -0
- package/clients/platform/windows/credential-store.ps1 +844 -0
- package/clients/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/clients/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/clients/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/clients/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/clients/workbench/contract.mjs +1314 -0
- package/clients/workbench/service.mjs +485 -0
- package/hellolynk-client-pack-manifest.json +884 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/integrations/workbuddy/package.json +15 -0
- package/integrations/workbuddy/server/host-lifecycle.mjs +249 -0
- package/integrations/workbuddy/server/mcp-proxy.mjs +258 -0
- package/integrations/workbuddy/server/mcp-server.mjs +357 -0
- package/integrations/workbuddy/server/model-call-origin.mjs +87 -0
- package/integrations/workbuddy/server/native-task-cdp.mjs +175 -0
- package/integrations/workbuddy/server/native-task-debug-startup.mjs +117 -0
- package/integrations/workbuddy/server/native-task-debug-startup.ps1 +40 -0
- package/integrations/workbuddy/server/native-task-mcp-registration.mjs +30 -0
- package/integrations/workbuddy/server/native-task-service-registration.mjs +115 -0
- package/integrations/workbuddy/server/native-task-worker.mjs +127 -0
- package/integrations/workbuddy/server/neutral-host-adapter.mjs +639 -0
- package/integrations/workbuddy/server/origin-hook.mjs +111 -0
- package/integrations/workbuddy/server/origin-routes.mjs +211 -0
- package/integrations/workbuddy/server/package-bootstrap.mjs +111 -0
- package/integrations/workbuddy/server/workbench-candidate.mjs +202 -0
- package/integrations/workbuddy/server/workbench-hidden-launcher.vbs +46 -0
- package/integrations/workbuddy/server/workbench-process.mjs +373 -0
- package/integrations/workbuddy/server/workbench-task.ps1 +196 -0
- package/integrations/workbuddy/skills/hellolynk-inbox/SKILL.md +9 -0
- package/integrations/workbuddy/skills/hellolynk-inbox/workbuddy.json +6 -0
- package/package.json +65 -4
- package/runtime/connect-activate.mjs +837 -0
- package/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/runtime/core/hellolynk-client-account.mjs +180 -0
- package/runtime/core/hellolynk-client-core.mjs +770 -0
- package/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/runtime/core/hellolynk-client-update.mjs +453 -0
- package/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/runtime/core/hellolynk-host.mjs +2374 -0
- package/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/runtime/core/hellolynk-release-retention.mjs +277 -0
- package/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/runtime/neutral-host-bootstrap.mjs +57 -0
- package/runtime/neutral-host-release-entry.mjs +81 -0
- package/runtime/neutral-host-release-loader.mjs +156 -0
- package/runtime/neutral-host-release-manifest.json +302 -0
- package/runtime/neutral-host-release-pin.json +4 -0
- package/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/runtime/platform/windows/credential-store.ps1 +844 -0
- package/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/runtime/workbench/contract.mjs +1314 -0
- package/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/runtime/workbench/host-capabilities.mjs +80 -0
- package/runtime/workbench/i18n.mjs +1127 -0
- package/runtime/workbench/loopback-surface.mjs +740 -0
- package/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/runtime/workbench/service.mjs +485 -0
- package/runtime/workbench/state.mjs +421 -0
- package/runtime/workbench/transports.mjs +171 -0
- package/runtime/workbench/ui/app.mjs +6120 -0
- package/runtime/workbench/ui/connection-form.mjs +155 -0
- package/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/runtime/workbench/ui/http-transport.mjs +247 -0
- package/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/runtime/workbench/ui/icons/close.svg +3 -0
- package/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/runtime/workbench/ui/icons/search.svg +3 -0
- package/runtime/workbench/ui/icons/security.svg +3 -0
- package/runtime/workbench/ui/icons/settings.svg +3 -0
- package/runtime/workbench/ui/index.html +139 -0
- package/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/runtime/workbench/ui/styles.css +686 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
- package/tools/hellolynk-install-client-skills.mjs +1211 -0
- package/workbuddy-package-manifest.json +1 -0
- package/workbuddy-package-pin.json +1 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { recordWorkBuddyModelCall, workBuddyModelHookCall } from "./model-call-origin.mjs";
|
|
3
|
+
// WorkBuddy PreToolUse hook: new sends keep best-effort origin registration.
|
|
4
|
+
// Inbox calls publish request evidence; a missing native source stops that
|
|
5
|
+
// invocation without a prompt. Reply origins are registered after the MCP guard.
|
|
6
|
+
// The installed native-task worker moves each record into the account's
|
|
7
|
+
// reply-route store; this process holds no binding or credentials. Each record
|
|
8
|
+
// carries the account scope the MCP proxy or worker last marked active (a non-secret hash),
|
|
9
|
+
// so a record made under one account is never imported into another.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
// Conversation IDs must also be valid WorkBuddy SDK IDs (128 characters).
|
|
16
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/;
|
|
17
|
+
// Some aggregated MCP modes drop the repeated server prefix from tool names.
|
|
18
|
+
const TOOL = /^mcp__hellolynk__(?:hellolynk_)?(send|reply)_authorized_message$/;
|
|
19
|
+
export const WORKBUDDY_ORIGIN_DIRECT_TOOL_MATCHER = "^mcp__hellolynk__(?:hellolynk_)?(?:send_authorized_message|reply_authorized_message|get_unread_count|list_authorized_inbox_messages|read_authorized_inbox_message|list_pending_messages|claim_message)$";
|
|
20
|
+
export const WORKBUDDY_ORIGIN_TOOL_MATCHER = `^(?:DeferExecuteTool|${WORKBUDDY_ORIGIN_DIRECT_TOOL_MATCHER.slice(1, -1)})$`;
|
|
21
|
+
export const WORKBUDDY_ACTIVE_SCOPE_FILE = "active-scope.json";
|
|
22
|
+
const SCOPE = /^[a-f0-9]{64}$/;
|
|
23
|
+
|
|
24
|
+
export function workBuddyOriginFromHook(payload) {
|
|
25
|
+
if (!payload || typeof payload !== "object" || payload.hook_event_name !== "PreToolUse") return null;
|
|
26
|
+
// agent_type is the active agent's name, even in a main conversation.
|
|
27
|
+
// A different store ID identifies a subagent of the named conversation.
|
|
28
|
+
// Equal IDs are accepted; no live restored-session payload is assumed here.
|
|
29
|
+
if (payload.agent_id && payload.agent_id !== payload.session_id) return null;
|
|
30
|
+
let name = payload.tool_name, input = objectInput(payload.tool_input);
|
|
31
|
+
// Current WorkBuddy invokes deferred MCP tools through this one wrapper.
|
|
32
|
+
// Unrelated wrapped calls return before reading or writing any route files.
|
|
33
|
+
if (name === "DeferExecuteTool") { name = input?.toolName; input = objectInput(input?.params); }
|
|
34
|
+
const tool = TOOL.exec(typeof name === "string" ? name : "");
|
|
35
|
+
if (!tool || !input) return null;
|
|
36
|
+
const reply = tool[1] === "reply", target = reply ? input.conversationKey : input.recipientDelegateId;
|
|
37
|
+
if (![payload.session_id, input.clientMessageId, target].every((value) => typeof value === "string" && ID.test(value))) return null;
|
|
38
|
+
return { threadId: payload.session_id, clientMessageId: input.clientMessageId,
|
|
39
|
+
conversationKey: reply ? target : null, peerDelegateId: reply ? null : target };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function objectInput(value) {
|
|
43
|
+
if (typeof value === "string") { try { value = JSON.parse(value); } catch { return null; } }
|
|
44
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Same name for the same record: a repeated hook run is idempotent.
|
|
48
|
+
export function workBuddyOriginDropName(origin) {
|
|
49
|
+
return `${crypto.createHash("sha256").update(JSON.stringify([origin.scopeHash, origin.clientMessageId, origin.threadId,
|
|
50
|
+
origin.conversationKey, origin.peerDelegateId])).digest("hex")}.json`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Written by the MCP proxy or worker for the connected account. Missing or
|
|
54
|
+
// damaged: the hook records nothing and replies stay in the HelloLynk inbox.
|
|
55
|
+
export async function readWorkBuddyActiveScope(inbox) {
|
|
56
|
+
try {
|
|
57
|
+
const file = path.join(inbox, WORKBUDDY_ACTIVE_SCOPE_FILE);
|
|
58
|
+
const stat = await fs.lstat(file);
|
|
59
|
+
if (!stat.isFile() || stat.size > 256) return null;
|
|
60
|
+
const marker = JSON.parse(await fs.readFile(file, "utf8"));
|
|
61
|
+
return Object.keys(marker || {}).sort().join() === "scopeHash,version" && marker.version === 1
|
|
62
|
+
&& SCOPE.test(marker.scopeHash || "") ? marker.scopeHash : null;
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function writeWorkBuddyOriginDrop(inbox, origin, now = Date.now()) {
|
|
67
|
+
await fs.mkdir(inbox, { recursive: true });
|
|
68
|
+
const file = path.join(inbox, workBuddyOriginDropName(origin));
|
|
69
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
await fs.writeFile(temporary, JSON.stringify({ version: 1, ...origin, createdAt: now }), { flag: "wx", mode: 0o600 });
|
|
72
|
+
await fs.rename(temporary, file);
|
|
73
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runWorkBuddyOriginHook(argv = process.argv.slice(2), input = process.stdin) {
|
|
77
|
+
const inbox = argv[0] === "--inbox" && argv.length === 2 ? argv[1] : "";
|
|
78
|
+
if (!path.isAbsolute(inbox)) return false;
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let size = 0;
|
|
81
|
+
for await (const chunk of input) {
|
|
82
|
+
size += chunk.length;
|
|
83
|
+
if (size > 1024 * 1024) return false;
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
}
|
|
86
|
+
let payload;
|
|
87
|
+
try { payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return false; }
|
|
88
|
+
const call = workBuddyModelHookCall(payload);
|
|
89
|
+
if (call) {
|
|
90
|
+
try { await recordWorkBuddyModelCall(inbox, payload, await readWorkBuddyActiveScope(inbox)); }
|
|
91
|
+
catch (error) { throw Object.assign(error, { code: "model_inbox_context_unavailable" }); }
|
|
92
|
+
// A reply registers its origin only after the MCP handler checks ownership.
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const origin = workBuddyOriginFromHook(payload);
|
|
96
|
+
if (!origin) return false;
|
|
97
|
+
const scopeHash = await readWorkBuddyActiveScope(inbox);
|
|
98
|
+
if (!scopeHash) return false;
|
|
99
|
+
await writeWorkBuddyOriginDrop(inbox, { scopeHash, ...origin });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
104
|
+
runWorkBuddyOriginHook().catch(error => {
|
|
105
|
+
if (error?.code === "model_inbox_context_unavailable") process.stdout.write(JSON.stringify({
|
|
106
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny",
|
|
107
|
+
permissionDecisionReason: "无法确定本次调用所在的对话,尚未查信或处理来信。" },
|
|
108
|
+
}) + "\n");
|
|
109
|
+
return false;
|
|
110
|
+
}).finally(() => { process.exitCode = 0; });
|
|
111
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { nativeReplyRouteLocation, rememberNativeReplyRoute } from "../../../clients/core/hellolynk-native-reply-routes.mjs";
|
|
7
|
+
import { WORKBUDDY_ACTIVE_SCOPE_FILE, WORKBUDDY_ORIGIN_DIRECT_TOOL_MATCHER, WORKBUDDY_ORIGIN_TOOL_MATCHER, readWorkBuddyActiveScope } from "./origin-hook.mjs";
|
|
8
|
+
|
|
9
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,198}$/;
|
|
10
|
+
const THREAD = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/;
|
|
11
|
+
const DROP = /^[a-f0-9]{64}\.json$/;
|
|
12
|
+
const SCOPE = /^[a-f0-9]{64}$/;
|
|
13
|
+
const TEMPORARY = /^[a-f0-9]{64}\.json\.\d+\.[a-f0-9]{12}\.tmp$/;
|
|
14
|
+
const STATUS = /^[a-z][a-z0-9_]{0,79}$/;
|
|
15
|
+
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
16
|
+
const failure = (code) => Object.assign(new Error(code), { code });
|
|
17
|
+
|
|
18
|
+
export function workBuddyOriginInbox(storeRoot) {
|
|
19
|
+
if (typeof storeRoot !== "string" || !path.isAbsolute(storeRoot)) throw failure("workbuddy_origin_inbox_invalid");
|
|
20
|
+
return path.join(storeRoot, "workbuddy-origin-inbox");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Tells the hook which account the MCP proxy or worker is connected to. Rewritten only
|
|
24
|
+
// when the account changes; the hash names no person and holds no secret.
|
|
25
|
+
export async function markWorkBuddyActiveScope(inbox, scope) {
|
|
26
|
+
const { scopeHash } = nativeReplyRouteLocation(scope);
|
|
27
|
+
if (await readWorkBuddyActiveScope(inbox) === scopeHash) return false;
|
|
28
|
+
await fs.mkdir(inbox, { recursive: true });
|
|
29
|
+
const file = path.join(inbox, WORKBUDDY_ACTIVE_SCOPE_FILE), temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
await fs.writeFile(temporary, JSON.stringify({ version: 1, scopeHash }), { flag: "wx", mode: 0o600 });
|
|
32
|
+
await fs.rename(temporary, file);
|
|
33
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Moves hook records into the current account's reply-route store. Records
|
|
38
|
+
// made under another account, invalid or conflicting records are dropped (the
|
|
39
|
+
// first route wins); a temporary file error keeps the record for the next tick.
|
|
40
|
+
export async function importWorkBuddyOriginDrops(inbox, scope, { remember = rememberNativeReplyRoute, now = Date.now, limit = 200 } = {}) {
|
|
41
|
+
const { scopeHash } = nativeReplyRouteLocation(scope);
|
|
42
|
+
let names;
|
|
43
|
+
try { names = await fs.readdir(inbox); }
|
|
44
|
+
catch (error) { if (error.code === "ENOENT") return 0; throw error; }
|
|
45
|
+
const drops = [];
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const file = path.join(inbox, name);
|
|
48
|
+
if (TEMPORARY.test(name)) {
|
|
49
|
+
// Left by an interrupted hook; a live hook renames within milliseconds.
|
|
50
|
+
const stat = await fs.lstat(file).catch(() => null);
|
|
51
|
+
if (stat && now() - stat.mtimeMs > 60 * 60 * 1000) await fs.rm(file, { force: true });
|
|
52
|
+
} else if (DROP.test(name) && drops.length < limit) {
|
|
53
|
+
// A file that cannot be read right now (busy, or removed meanwhile) waits for the next tick.
|
|
54
|
+
try { drops.push({ file, record: await readDrop(file) }); } catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
drops.sort((a, b) => (a.record?.createdAt ?? 0) - (b.record?.createdAt ?? 0) || a.file.localeCompare(b.file));
|
|
58
|
+
let imported = 0;
|
|
59
|
+
for (const { file, record } of drops) {
|
|
60
|
+
if (record?.scopeHash === scopeHash) {
|
|
61
|
+
try { await remember(scope, record); imported += 1; }
|
|
62
|
+
catch (error) { if (!["native_reply_route_conflict", "native_reply_route_invalid"].includes(error?.message)) continue; }
|
|
63
|
+
}
|
|
64
|
+
await fs.rm(file, { force: true });
|
|
65
|
+
}
|
|
66
|
+
return imported;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// null marks a file that is not a valid record, which is deleted. A file
|
|
70
|
+
// error is thrown instead, so the caller keeps the file.
|
|
71
|
+
async function readDrop(file) {
|
|
72
|
+
const stat = await fs.lstat(file);
|
|
73
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return null;
|
|
74
|
+
const text = await fs.readFile(file, "utf8");
|
|
75
|
+
let record;
|
|
76
|
+
try { record = JSON.parse(text); } catch { return null; }
|
|
77
|
+
const keys = Object.keys(record || {}).sort().join();
|
|
78
|
+
if (keys !== "clientMessageId,conversationKey,createdAt,peerDelegateId,scopeHash,threadId,version" || record.version !== 1
|
|
79
|
+
|| !SCOPE.test(record.scopeHash || "") || !THREAD.test(record.threadId || "") || !ID.test(record.clientMessageId || "")
|
|
80
|
+
|| !Number.isSafeInteger(record.createdAt) || (record.conversationKey === null) === (record.peerDelegateId === null)
|
|
81
|
+
|| ![record.conversationKey, record.peerDelegateId].every((value) => value === null || ID.test(value))) return null;
|
|
82
|
+
return { scopeHash: record.scopeHash, threadId: record.threadId, clientMessageId: record.clientMessageId,
|
|
83
|
+
conversationKey: record.conversationKey, peerDelegateId: record.peerDelegateId, createdAt: record.createdAt };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One file per reply delivered to an original conversation. It is written
|
|
87
|
+
// before the desktop call, so a crash or unknown result is never repeated.
|
|
88
|
+
export function createWorkBuddyOriginLedger(scope) {
|
|
89
|
+
const { scopeHash } = nativeReplyRouteLocation(scope);
|
|
90
|
+
const directory = path.join(path.resolve(scope.runtimeRoot), "native-origin-deliveries", scopeHash);
|
|
91
|
+
const file = (id) => { if (!ID.test(id || "")) throw failure("workbuddy_native_pending_invalid"); return path.join(directory, `${hash(id)}.json`); };
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
async get(localMessageId) {
|
|
94
|
+
let text;
|
|
95
|
+
try { text = await fs.readFile(file(localMessageId), "utf8"); }
|
|
96
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
97
|
+
// An unreadable record still means a native write may have started.
|
|
98
|
+
try { return JSON.parse(text); } catch { return { status: "dispatch_unknown" }; }
|
|
99
|
+
},
|
|
100
|
+
async begin(localMessageId, threadId) {
|
|
101
|
+
const target = file(localMessageId);
|
|
102
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
103
|
+
let handle;
|
|
104
|
+
try {
|
|
105
|
+
handle = await fs.open(target, "wx", 0o600);
|
|
106
|
+
await handle.writeFile(JSON.stringify({ localMessageId, threadId, status: "dispatching" }));
|
|
107
|
+
await handle.sync();
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) { if (error.code === "EEXIST") return false; throw error; }
|
|
110
|
+
finally { await handle?.close(); }
|
|
111
|
+
},
|
|
112
|
+
async finish(localMessageId, threadId, status) {
|
|
113
|
+
if (!STATUS.test(status || "")) throw failure("workbuddy_native_dispatch_unavailable");
|
|
114
|
+
const target = file(localMessageId), temporary = `${target}.${crypto.randomUUID()}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
await fs.writeFile(temporary, JSON.stringify({ localMessageId, threadId, status }), { flag: "wx", mode: 0o600 });
|
|
117
|
+
await fs.rename(temporary, target);
|
|
118
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
119
|
+
},
|
|
120
|
+
// Only for a record whose desktop call has not started.
|
|
121
|
+
async undo(localMessageId) { await fs.rm(file(localMessageId), { force: true }); },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function workBuddySettingsPath(env = process.env) {
|
|
126
|
+
return path.join(env.WORKBUDDY_CONFIG_DIR || path.join(os.homedir(), ".workbuddy"), "settings.json");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// WorkBuddy runs a command whose program is powershell.exe directly, with or
|
|
130
|
+
// without Git Bash. The hook inherits stdin, so the payload stays byte-exact;
|
|
131
|
+
// piping it through PowerShell would re-encode non-ASCII text.
|
|
132
|
+
export function workBuddyOriginHookCommand({ nodePath, hookPath, inbox,
|
|
133
|
+
powershellPath = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe") }) {
|
|
134
|
+
for (const value of [powershellPath, nodePath, hookPath, inbox]) {
|
|
135
|
+
if (typeof value !== "string" || !path.win32.isAbsolute(value) || /["\r\n\0`]|\$\{/.test(value)) throw failure("workbuddy_origin_hook_path_invalid");
|
|
136
|
+
}
|
|
137
|
+
const quote = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
138
|
+
return `"${powershellPath}" -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; & ${quote(nodePath)} ${quote(hookPath)} --inbox ${quote(inbox)} 2>$null; exit 0"`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Called when the installed native-task service starts, so the hook always
|
|
142
|
+
// runs this release's file. A failed registration never stops delivery;
|
|
143
|
+
// replies to sends it missed stay in the HelloLynk inbox.
|
|
144
|
+
export async function prepareWorkBuddyOriginHook({ storeRoot, settingsPath, nodePath = process.execPath,
|
|
145
|
+
hookPath = fileURLToPath(new URL("./origin-hook.mjs", import.meta.url)), platform = process.platform } = {}) {
|
|
146
|
+
const inbox = workBuddyOriginInbox(storeRoot);
|
|
147
|
+
let hook = { changed: false, status: "unsupported_platform" };
|
|
148
|
+
if (platform === "win32") {
|
|
149
|
+
try { hook = await ensureWorkBuddyOriginHook({ settingsPath, command: workBuddyOriginHookCommand({ nodePath, hookPath, inbox }) }); }
|
|
150
|
+
catch (error) { hook = { changed: false, status: "unavailable", errorCode: /^workbuddy_[a-z_]{1,80}$/.test(error?.code || "") ? error.code : "workbuddy_origin_hook_unavailable" }; }
|
|
151
|
+
}
|
|
152
|
+
return { inbox, hook };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Adds or replaces the one HelloLynk entry; other hooks and settings stay as they are.
|
|
156
|
+
export async function ensureWorkBuddyOriginHook({ command, settingsPath = workBuddySettingsPath() } = {}) {
|
|
157
|
+
if (typeof command !== "string" || !command.includes("origin-hook.mjs")) throw failure("workbuddy_origin_hook_command_invalid");
|
|
158
|
+
const ours = { matcher: WORKBUDDY_ORIGIN_TOOL_MATCHER, hooks: [{ type: "command", command, timeout: 30 }] };
|
|
159
|
+
return updatePreToolUse(settingsPath, (entries) => {
|
|
160
|
+
const index = entries.findIndex(isOriginHook);
|
|
161
|
+
return index < 0 ? [...entries, ours] : entries.flatMap((entry, at) => at === index ? [ours] : isOriginHook(entry) ? [] : [entry]);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function removeWorkBuddyOriginHook({ command, settingsPath = workBuddySettingsPath() } = {}) {
|
|
166
|
+
if (typeof command !== "string" || !command.includes("origin-hook.mjs")) throw failure("workbuddy_origin_hook_command_invalid");
|
|
167
|
+
// An old release or isolated task must not remove the active installation's hook.
|
|
168
|
+
return updatePreToolUse(settingsPath, (entries) => entries.flatMap((entry) => {
|
|
169
|
+
if (!isOriginHook(entry)) return [entry];
|
|
170
|
+
const hooks = entry.hooks.filter((hook) => hook.command !== command);
|
|
171
|
+
return hooks.length ? [{ ...entry, hooks }] : [];
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isOriginHook(entry) {
|
|
176
|
+
// Recognize installed B248 matchers only to replace/remove their old hook.
|
|
177
|
+
const previous = '^mcp__hellolynk__(?:hellolynk_)?(send|reply)_authorized_message$';
|
|
178
|
+
return [WORKBUDDY_ORIGIN_DIRECT_TOOL_MATCHER, WORKBUDDY_ORIGIN_TOOL_MATCHER,
|
|
179
|
+
previous, `^(?:DeferExecuteTool|${previous.slice(1, -1)})$`].includes(entry?.matcher) && Array.isArray(entry.hooks)
|
|
180
|
+
&& entry.hooks.some((hook) => typeof hook?.command === "string" && hook.command.includes("origin-hook.mjs"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function updatePreToolUse(settingsPath, change) {
|
|
184
|
+
let text;
|
|
185
|
+
// A missing file means WorkBuddy is not set up for this user; never create its directory.
|
|
186
|
+
try { text = await fs.readFile(settingsPath, "utf8"); }
|
|
187
|
+
catch (error) { if (error.code === "ENOENT") return { changed: false, status: "settings_missing" }; throw error; }
|
|
188
|
+
let settings;
|
|
189
|
+
try { settings = JSON.parse(text.replace(/^\uFEFF/, "")); } catch { throw failure("workbuddy_settings_invalid"); }
|
|
190
|
+
const hooks = settings?.hooks ?? {};
|
|
191
|
+
const entries = hooks?.PreToolUse ?? [];
|
|
192
|
+
if (!plain(settings) || !plain(hooks) || !Array.isArray(entries)) throw failure("workbuddy_settings_invalid");
|
|
193
|
+
const next = change(entries);
|
|
194
|
+
if (JSON.stringify(next) === JSON.stringify(entries)) return { changed: false, status: "unchanged" };
|
|
195
|
+
const nextHooks = { ...hooks, PreToolUse: next };
|
|
196
|
+
if (!next.length) delete nextHooks.PreToolUse;
|
|
197
|
+
const nextSettings = { ...settings, hooks: nextHooks };
|
|
198
|
+
if (!Object.keys(nextHooks).length) delete nextSettings.hooks;
|
|
199
|
+
const temporary = `${settingsPath}.${crypto.randomUUID()}.tmp`;
|
|
200
|
+
try {
|
|
201
|
+
// Same layout as WorkBuddy's own settings writes.
|
|
202
|
+
await fs.writeFile(temporary, JSON.stringify(nextSettings, null, 2), { flag: "wx" });
|
|
203
|
+
// Replace only the version read above. If the person or WorkBuddy changed
|
|
204
|
+
// it meanwhile, keep theirs; the next service start registers again.
|
|
205
|
+
if (await fs.readFile(settingsPath, "utf8").catch(() => null) !== text) return { changed: false, status: "settings_changed" };
|
|
206
|
+
await fs.rename(temporary, settingsPath);
|
|
207
|
+
} finally { await fs.rm(temporary, { force: true }); }
|
|
208
|
+
return { changed: true, status: "updated" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function plain(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
const ENTRY = "integrations/workbuddy/server/mcp-proxy.mjs";
|
|
6
|
+
const REQUIRED = new Set([
|
|
7
|
+
ENTRY,
|
|
8
|
+
"integrations/workbuddy/package.json",
|
|
9
|
+
"integrations/workbuddy/server/mcp-server.mjs",
|
|
10
|
+
"integrations/workbuddy/server/neutral-host-adapter.mjs",
|
|
11
|
+
"integrations/workbuddy/server/workbench-candidate.mjs",
|
|
12
|
+
"integrations/workbuddy/server/workbench-process.mjs",
|
|
13
|
+
"integrations/workbuddy/server/workbench-task.ps1",
|
|
14
|
+
"integrations/workbuddy/server/workbench-hidden-launcher.vbs",
|
|
15
|
+
"clients/core/hellolynk-runtime-safety.cjs",
|
|
16
|
+
"integrations/workbuddy/server/package-bootstrap.mjs",
|
|
17
|
+
"integrations/workbuddy/skills/hellolynk-inbox/SKILL.md",
|
|
18
|
+
"integrations/workbuddy/skills/hellolynk-inbox/workbuddy.json",
|
|
19
|
+
"clients/core/hellolynk-local-data-migration.mjs",
|
|
20
|
+
"clients/core/hellolynk-local-data-protection.mjs",
|
|
21
|
+
"clients/core/hellolynk-local-identity-trust.mjs",
|
|
22
|
+
"clients/core/hellolynk-neutral-host-workbench-read-model.mjs",
|
|
23
|
+
"clients/workbench/contract.mjs",
|
|
24
|
+
"clients/platform/windows/credential-store.ps1",
|
|
25
|
+
"clients/platform/windows/hellolynk-host-protected-store.ps1",
|
|
26
|
+
"clients/platform/windows/hellolynk-local-data-key.ps1",
|
|
27
|
+
"clients/platform/windows/hellolynk-human-confirmation-ledger.ps1",
|
|
28
|
+
"clients/platform/windows/hellolynk-native-human-confirmation-broker.ps1",
|
|
29
|
+
"runtime/neutral-host-bootstrap.mjs",
|
|
30
|
+
"runtime/neutral-host-release-loader.mjs",
|
|
31
|
+
"runtime/neutral-host-release-entry.mjs",
|
|
32
|
+
"runtime/neutral-host-release-manifest.json",
|
|
33
|
+
"runtime/neutral-host-release-pin.json",
|
|
34
|
+
"runtime/core/hellolynk-host-protected-store.mjs",
|
|
35
|
+
"runtime/core/hellolynk-local-data-migration.mjs",
|
|
36
|
+
"runtime/core/hellolynk-local-data-protection.mjs",
|
|
37
|
+
"runtime/core/hellolynk-local-identity-trust.mjs",
|
|
38
|
+
"runtime/core/hellolynk-neutral-host-assembly.mjs",
|
|
39
|
+
"runtime/core/hellolynk-neutral-host-workbench-read-model.mjs",
|
|
40
|
+
"runtime/core/hellolynk-workbench-action-grants.mjs",
|
|
41
|
+
"runtime/workbench/contract.mjs",
|
|
42
|
+
"runtime/workbench/candidate-bootstrap.mjs",
|
|
43
|
+
"runtime/platform/windows/hellolynk-local-data-key.ps1",
|
|
44
|
+
]);
|
|
45
|
+
export async function loadPinnedWorkBuddyProxy(options={}) {
|
|
46
|
+
const root=absolute(options.root), expected=hash(options.expectedManifestSha256);
|
|
47
|
+
const [mb,pb]=await Promise.all([safeRead(root,"workbuddy-package-manifest.json"),safeRead(root,"workbuddy-package-pin.json")]);
|
|
48
|
+
if(sha(mb)!==expected)throw code("workbuddy_package_manifest_mismatch"); const manifest=parse(mb),pin=parse(pb);
|
|
49
|
+
if(!plain(pin)||!exact(pin,["contract","manifestSha256"])||pin.contract!=="hellolynk-workbuddy-immutable-package-pin-v1"||pin.manifestSha256!==expected)throw code("workbuddy_package_pin_invalid");
|
|
50
|
+
if(!plain(manifest)||!exact(manifest,["contract","entry","files"])||manifest.contract!=="hellolynk-workbuddy-immutable-package-v1"||manifest.entry!==ENTRY||!Array.isArray(manifest.files))throw code("workbuddy_package_manifest_invalid");
|
|
51
|
+
const required=new Set(REQUIRED),seen=new Set(); for(const record of manifest.files){if(!plain(record)||!exact(record,["path","sha256"])||!safeRelative(record.path)||!/^[a-f0-9]{64}$/.test(String(record.sha256||""))||seen.has(record.path))throw code("workbuddy_package_manifest_invalid");seen.add(record.path);required.delete(record.path);if(sha(await safeRead(root,record.path))!==record.sha256)throw code("workbuddy_package_file_integrity_invalid");}
|
|
52
|
+
if(required.size)throw code("workbuddy_package_closure_incomplete"); return import(pathToFileURL(path.join(root,...ENTRY.split("/"))).href);
|
|
53
|
+
}
|
|
54
|
+
export async function runWorkBuddyPackageBootstrap(args = process.argv.slice(2)) {
|
|
55
|
+
if (!["run", "--wait-for-pending", "handoff-workbench", "workbench", "workbench-service", "native-task-service"].includes(args[0])
|
|
56
|
+
|| args.length !== 5 || args[1] !== "--root" || args[3] !== "--expected-manifest-sha256") {
|
|
57
|
+
throw code("workbuddy_package_arguments_invalid");
|
|
58
|
+
}
|
|
59
|
+
const options = { root: args[2], expectedManifestSha256: args[4] };
|
|
60
|
+
// Every entry, including the detached page, verifies the immutable closure
|
|
61
|
+
// before importing adapter or UI code.
|
|
62
|
+
const proxy = await loadPinnedWorkBuddyProxy(options);
|
|
63
|
+
if (args[0] === "--wait-for-pending") {
|
|
64
|
+
const stop = new AbortController();
|
|
65
|
+
const terminate = () => stop.abort();
|
|
66
|
+
process.once("SIGTERM", terminate);
|
|
67
|
+
process.once("SIGINT", terminate);
|
|
68
|
+
try { await proxy.waitForWorkBuddyPending({ signal: stop.signal }); }
|
|
69
|
+
finally { process.off("SIGTERM", terminate); process.off("SIGINT", terminate); }
|
|
70
|
+
} else if (args[0] === "run") {
|
|
71
|
+
await proxy.runWorkBuddyMcpProxy({ openLocalWeb: () => proxy.launchDetachedWorkBuddyWorkbench(options) });
|
|
72
|
+
} else if (args[0] === "handoff-workbench") {
|
|
73
|
+
process.stdout.write(`${JSON.stringify(await proxy.handoffWorkBuddyWorkbench(options))}\n`);
|
|
74
|
+
} else if (["workbench-service", "native-task-service"].includes(args[0])) {
|
|
75
|
+
const stop = new AbortController();
|
|
76
|
+
const terminate = () => stop.abort();
|
|
77
|
+
process.once("SIGTERM", terminate);
|
|
78
|
+
process.once("SIGINT", terminate);
|
|
79
|
+
try {
|
|
80
|
+
if (args[0] === "native-task-service") await proxy.runWorkBuddyNativeTaskService({ ...options, originHook: true, signal: stop.signal });
|
|
81
|
+
else await proxy.runWorkBuddyWorkbenchService({ ...options, signal: stop.signal });
|
|
82
|
+
}
|
|
83
|
+
finally { process.off("SIGTERM", terminate); process.off("SIGINT", terminate); }
|
|
84
|
+
} else {
|
|
85
|
+
const stop = new AbortController();
|
|
86
|
+
const terminate = () => stop.abort();
|
|
87
|
+
process.once("SIGTERM", terminate);
|
|
88
|
+
process.once("SIGINT", terminate);
|
|
89
|
+
// A launch abandoned before readiness must not leave an idle worker.
|
|
90
|
+
const deadline = setTimeout(() => process.exit(1), 90_000);
|
|
91
|
+
deadline.unref();
|
|
92
|
+
try {
|
|
93
|
+
await proxy.runWorkBuddyWorkbenchProcess({
|
|
94
|
+
signal: stop.signal,
|
|
95
|
+
publishReady: (value) => new Promise((resolve, reject) => {
|
|
96
|
+
if (!process.connected) return reject(code("workbuddy_local_web_launch_handoff_missing"));
|
|
97
|
+
process.send(value, (error) => {
|
|
98
|
+
if (error) reject(error);
|
|
99
|
+
else { clearTimeout(deadline); resolve(); }
|
|
100
|
+
});
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
clearTimeout(deadline);
|
|
105
|
+
process.off("SIGTERM", terminate);
|
|
106
|
+
process.off("SIGINT", terminate);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function absolute(v){const r=String(v||"");if(!path.isAbsolute(r)||path.resolve(r)!==r)throw code("workbuddy_package_root_required");return r}function hash(v){const r=String(v||"");if(!/^[a-f0-9]{64}$/.test(r))throw code("workbuddy_package_expected_manifest_required");return r}function safeRelative(v){return typeof v==="string"&&/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(v)&&!v.includes("..")&&!v.startsWith("/")}async function safeRead(root,relative){const f=path.resolve(root,...relative.split("/"));if(!f.startsWith(`${root}${path.sep}`))throw code("workbuddy_package_path_invalid");try{const s=await fs.lstat(f);if(!s.isFile()||s.isSymbolicLink()||s.size>4*1024*1024)throw 0;return await fs.readFile(f)}catch{throw code("workbuddy_package_file_unavailable")}}function parse(v){try{return JSON.parse(v)}catch{throw code("workbuddy_package_manifest_invalid")}}function exact(v,k){const a=Object.keys(v).sort(),b=[...k].sort();return a.length===b.length&&a.every((x,i)=>x===b[i])}function sha(v){return crypto.createHash("sha256").update(v).digest("hex")}function plain(v){return Boolean(v)&&typeof v==="object"&&!Array.isArray(v)}function code(v){const e=new Error(v);e.code=v;return e}
|
|
111
|
+
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))runWorkBuddyPackageBootstrap().catch(e=>{process.stderr.write(`${JSON.stringify({ok:false,error:/^(?:workbuddy|neutral|host)_[a-z0-9_]{1,100}$/.test(e?.code||"")?e.code:"workbuddy_package_failed"})}\n`);process.exitCode=1});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createWorkbenchAccountOpener, createWorkbenchConnection, workbenchConnectionError, isWorkbenchConnectionRequired } from "../../../clients/core/hellolynk-workbench-connection.mjs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lstat } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
let active = null;
|
|
7
|
+
const PACKAGED_WORKBENCH = new URL("../../../runtime/workbench/candidate-bootstrap.mjs", import.meta.url);
|
|
8
|
+
const SOURCE_WORKBENCH = new URL("../../../clients/workbench/candidate-bootstrap.mjs", import.meta.url);
|
|
9
|
+
const ACTION_GRANT = /^[A-Za-z0-9._~-]{32,512}$/;
|
|
10
|
+
|
|
11
|
+
export async function bootstrapWorkBuddyWorkbenchCandidate(options = {}) {
|
|
12
|
+
const sharedWorkbench = options.bootstrapWorkbenchCandidate
|
|
13
|
+
? injectedSharedWorkbench(options)
|
|
14
|
+
: await loadSharedWorkbench();
|
|
15
|
+
const {
|
|
16
|
+
bootstrapWorkbenchCandidate: _ignored,
|
|
17
|
+
workbenchContract: _ignoredContract,
|
|
18
|
+
adapter,
|
|
19
|
+
...candidateOptions
|
|
20
|
+
} = options;
|
|
21
|
+
requireReadAdapter(adapter);
|
|
22
|
+
if (typeof adapter.start === "function") await adapter.start().catch((error) => { if (!isWorkbenchConnectionRequired(error)) throw error; });
|
|
23
|
+
return sharedWorkbench.bootstrapWorkbenchCandidate("workbuddy", {
|
|
24
|
+
openAccount: options.openAccount || createWorkbenchAccountOpener(),
|
|
25
|
+
connectAccount: options.connectAccount || createWorkbenchConnection({ client: "workbuddy", releaseRoot: path.resolve(fileURLToPath(new URL("../../../", import.meta.url))) }),
|
|
26
|
+
...candidateOptions,
|
|
27
|
+
adapter: createWorkBuddyWorkbenchAdapterFacade(adapter, sharedWorkbench.contract),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function launchWorkBuddyWorkbench(options = {}) {
|
|
32
|
+
const adapter = options.adapter;
|
|
33
|
+
requireReadAdapter(adapter);
|
|
34
|
+
if (typeof adapter.start === "function") await adapter.start().catch((error) => { if (!isWorkbenchConnectionRequired(error)) throw error; });
|
|
35
|
+
if (active && active.adapter === adapter) {
|
|
36
|
+
active.candidate.surface.armNavigation();
|
|
37
|
+
return active.result;
|
|
38
|
+
}
|
|
39
|
+
if (active) await closeWorkBuddyWorkbench();
|
|
40
|
+
const sharedWorkbench = options.bootstrapWorkbenchCandidate
|
|
41
|
+
? injectedSharedWorkbench(options)
|
|
42
|
+
: await loadSharedWorkbench();
|
|
43
|
+
const workbenchAdapter = createWorkBuddyWorkbenchAdapterFacade(adapter, sharedWorkbench.contract);
|
|
44
|
+
const candidate = await sharedWorkbench.bootstrapWorkbenchCandidate("workbuddy", {
|
|
45
|
+
openAccount: options.openAccount || createWorkbenchAccountOpener(),
|
|
46
|
+
connectAccount: options.connectAccount || createWorkbenchConnection({ client: "workbuddy", releaseRoot: path.resolve(fileURLToPath(new URL("../../../", import.meta.url))) }),
|
|
47
|
+
adapter: workbenchAdapter,
|
|
48
|
+
...(options.port == null ? {} : { port: options.port }),
|
|
49
|
+
pagePath: "/inbox",
|
|
50
|
+
});
|
|
51
|
+
const result = Object.freeze({
|
|
52
|
+
ok: true,
|
|
53
|
+
host: candidate.surface,
|
|
54
|
+
cleanUrl: candidate.cleanUrl,
|
|
55
|
+
status: "ready_for_browser_navigation",
|
|
56
|
+
});
|
|
57
|
+
active = { adapter, candidate, result };
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function closeWorkBuddyWorkbench() {
|
|
62
|
+
if (!active) return;
|
|
63
|
+
const current = active;
|
|
64
|
+
active = null;
|
|
65
|
+
await current.candidate.close();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createWorkBuddyWorkbenchAdapterFacade(adapter, contract) {
|
|
69
|
+
requireReadAdapter(adapter);
|
|
70
|
+
const facade = {
|
|
71
|
+
readSnapshot: (input) => Promise.resolve().then(() => adapter.readWorkbenchSnapshot(input)).catch((error) => { throw workbenchConnectionError(error); }),
|
|
72
|
+
};
|
|
73
|
+
if (typeof adapter.readWorkbenchChanges === "function") {
|
|
74
|
+
facade.readChanges = (input) => adapter.readWorkbenchChanges(input);
|
|
75
|
+
}
|
|
76
|
+
if (supportsF3Contract(contract) && hasHostActionGrantPair(adapter)) {
|
|
77
|
+
const issueWorkbenchActionGrant = adapter.issueWorkbenchActionGrant.bind(adapter);
|
|
78
|
+
const executeWorkbenchCommand = adapter.executeWorkbenchCommand.bind(adapter);
|
|
79
|
+
facade.issueActionGrant = async (command) => requireIssuedActionGrant(
|
|
80
|
+
await issueWorkbenchActionGrant(contract.validateWorkbenchCommand(command)),
|
|
81
|
+
"workbuddy_workbench_action_grant_invalid",
|
|
82
|
+
);
|
|
83
|
+
facade.executeCommand = async (command, context) => {
|
|
84
|
+
const validated = contract.validateWorkbenchCommand(command);
|
|
85
|
+
const normalizedContext = validated.command === "message.send-status"
|
|
86
|
+
? requireEmptyContext(context, "workbuddy_workbench_action_grant_invalid")
|
|
87
|
+
: requireActionGrantContext(context, "workbuddy_workbench_action_grant_required");
|
|
88
|
+
return contract.validateWorkbenchCommandResult(
|
|
89
|
+
await executeWorkbenchCommand(validated, normalizedContext),
|
|
90
|
+
validated.command,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(facade);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadSharedWorkbench() {
|
|
98
|
+
const moduleUrl = await selectWorkbenchModule();
|
|
99
|
+
let loaded;
|
|
100
|
+
let contract;
|
|
101
|
+
try {
|
|
102
|
+
[loaded, contract] = await Promise.all([
|
|
103
|
+
import(moduleUrl.href),
|
|
104
|
+
import(new URL("./contract.mjs", moduleUrl).href),
|
|
105
|
+
]);
|
|
106
|
+
} catch {
|
|
107
|
+
throw codeError("workbuddy_shared_workbench_unavailable");
|
|
108
|
+
}
|
|
109
|
+
if (typeof loaded.bootstrapWorkbenchCandidate !== "function") {
|
|
110
|
+
throw codeError("workbuddy_shared_workbench_unavailable");
|
|
111
|
+
}
|
|
112
|
+
return Object.freeze({ bootstrapWorkbenchCandidate: loaded.bootstrapWorkbenchCandidate, contract });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function injectedSharedWorkbench(options) {
|
|
116
|
+
if (typeof options.bootstrapWorkbenchCandidate !== "function") {
|
|
117
|
+
throw codeError("workbuddy_shared_workbench_unavailable");
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
bootstrapWorkbenchCandidate: options.bootstrapWorkbenchCandidate,
|
|
121
|
+
contract: options.workbenchContract || null,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function selectWorkbenchModule() {
|
|
126
|
+
const packaged = await regularFile(PACKAGED_WORKBENCH);
|
|
127
|
+
if (packaged) return PACKAGED_WORKBENCH;
|
|
128
|
+
if (await regularFile(SOURCE_WORKBENCH)) return SOURCE_WORKBENCH;
|
|
129
|
+
throw codeError("workbuddy_shared_workbench_unavailable");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function regularFile(url) {
|
|
133
|
+
try {
|
|
134
|
+
const metadata = await lstat(fileURLToPath(url));
|
|
135
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
136
|
+
throw codeError("workbuddy_shared_workbench_unavailable");
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === "ENOENT") return false;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function requireReadAdapter(adapter) {
|
|
146
|
+
if (!adapter || typeof adapter.readWorkbenchSnapshot !== "function") {
|
|
147
|
+
throw codeError("workbuddy_shared_workbench_adapter_required");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function supportsF3Contract(value) {
|
|
152
|
+
return Boolean(value)
|
|
153
|
+
&& value.HELLOLYNK_WORKBENCH_SNAPSHOT_CONTRACT === "hellolynk-workbench-snapshot-v12"
|
|
154
|
+
&& value.HELLOLYNK_WORKBENCH_SNAPSHOT_SCHEMA_VERSION === 12
|
|
155
|
+
&& value.HELLOLYNK_WORKBENCH_COMMAND_CONTRACT === "hellolynk-workbench-command-v6"
|
|
156
|
+
&& value.HELLOLYNK_WORKBENCH_RESULT_CONTRACT === "hellolynk-workbench-command-result-v6"
|
|
157
|
+
&& value.HELLOLYNK_WORKBENCH_SCHEMA_VERSION === 6
|
|
158
|
+
&& typeof value.validateWorkbenchCommand === "function"
|
|
159
|
+
&& typeof value.validateWorkbenchCommandResult === "function";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hasHostActionGrantPair(adapter) {
|
|
163
|
+
return typeof adapter.issueWorkbenchActionGrant === "function"
|
|
164
|
+
&& adapter.issueWorkbenchActionGrant.length >= 1
|
|
165
|
+
&& typeof adapter.executeWorkbenchCommand === "function"
|
|
166
|
+
&& adapter.executeWorkbenchCommand.length >= 2;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function requireActionGrantContext(value, errorCode) {
|
|
170
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
171
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
172
|
+
|| Object.keys(value).length !== 1 || !Object.hasOwn(value, "actionGrant")
|
|
173
|
+
|| typeof value.actionGrant !== "string" || !ACTION_GRANT.test(value.actionGrant)) {
|
|
174
|
+
throw codeError(errorCode);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function requireEmptyContext(value, errorCode) {
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
181
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
182
|
+
|| Object.keys(value).length !== 0) {
|
|
183
|
+
throw codeError(errorCode);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function requireIssuedActionGrant(value, errorCode) {
|
|
189
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
190
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
191
|
+
|| Object.keys(value).length !== 1 || !Object.hasOwn(value, "actionGrant")
|
|
192
|
+
|| typeof value.actionGrant !== "string" || !ACTION_GRANT.test(value.actionGrant)) {
|
|
193
|
+
throw codeError(errorCode);
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function codeError(code) {
|
|
199
|
+
const error = new Error(code);
|
|
200
|
+
error.code = code;
|
|
201
|
+
return error;
|
|
202
|
+
}
|