@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,96 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$/;
|
|
6
|
+
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
7
|
+
const valid = (value) => typeof value === "string" && ID.test(value);
|
|
8
|
+
const fail = () => { throw new Error("native_reply_route_invalid"); };
|
|
9
|
+
|
|
10
|
+
// Only local executor metadata supplies threadId. Neither message text nor a
|
|
11
|
+
// model-supplied tool argument can name the native destination.
|
|
12
|
+
export async function rememberNativeReplyRoute(scope, input) {
|
|
13
|
+
const { directory, scopeHash } = location(scope);
|
|
14
|
+
const record = { version: 1, scopeHash, clientMessageId: input.clientMessageId,
|
|
15
|
+
threadId: input.threadId, conversationKey: input.conversationKey ?? null,
|
|
16
|
+
peerDelegateId: input.peerDelegateId ?? null };
|
|
17
|
+
validate(record, scopeHash, input.clientMessageId);
|
|
18
|
+
await safeDirectory(scope.runtimeRoot);
|
|
19
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
await safeDirectory(directory);
|
|
21
|
+
const file = path.join(directory, `${hash(record.clientMessageId)}.json`);
|
|
22
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
|
|
23
|
+
let handle, temporaryCreated = false;
|
|
24
|
+
try {
|
|
25
|
+
// Publish only a complete, synced record. A link is exclusive and atomic;
|
|
26
|
+
// an existing clientMessageId is never replaced or taken over.
|
|
27
|
+
handle = await fs.open(temporary, "wx", 0o600);
|
|
28
|
+
temporaryCreated = true;
|
|
29
|
+
await handle.writeFile(JSON.stringify(record));
|
|
30
|
+
await handle.sync();
|
|
31
|
+
await handle.close();
|
|
32
|
+
handle = null;
|
|
33
|
+
try {
|
|
34
|
+
await fs.link(temporary, file);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== "EEXIST") throw error;
|
|
37
|
+
const existing = await readRecord(file, scopeHash, record.clientMessageId);
|
|
38
|
+
if (JSON.stringify(existing) !== JSON.stringify(record)) throw new Error("native_reply_route_conflict");
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
// The body either published a complete route or already threw its cause.
|
|
42
|
+
// Cleanup must not replace that result with a secondary close/unlink error.
|
|
43
|
+
try { await handle?.close(); } catch {}
|
|
44
|
+
if (temporaryCreated) await fs.unlink(temporary).catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readNativeReplyRoute(scope, message) {
|
|
49
|
+
if (message.replyToClientMessageId == null) return null;
|
|
50
|
+
if (!valid(message.replyToClientMessageId)) fail();
|
|
51
|
+
const { directory, scopeHash } = location(scope);
|
|
52
|
+
try {
|
|
53
|
+
await safeDirectory(directory);
|
|
54
|
+
const record = await readRecord(path.join(directory, `${hash(message.replyToClientMessageId)}.json`),
|
|
55
|
+
scopeHash, message.replyToClientMessageId);
|
|
56
|
+
if (record.conversationKey && record.conversationKey !== message.conversationKey) fail();
|
|
57
|
+
if (record.peerDelegateId && record.peerDelegateId !== message.peerDelegateId) fail();
|
|
58
|
+
return record;
|
|
59
|
+
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function nativeReplyRouteLocation(scope) {
|
|
63
|
+
if (!scope || !path.isAbsolute(scope.runtimeRoot || "") || !["codex", "claude-code", "dsh", "openclaw", "workbuddy", "cursor", "trae-work"].includes(scope.adapterId)) fail();
|
|
64
|
+
for (const key of ["region", "principalId", "delegateId"])
|
|
65
|
+
if (typeof scope[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,240}$/.test(scope[key])) fail();
|
|
66
|
+
const root = path.resolve(scope.runtimeRoot);
|
|
67
|
+
const scopeHash = hash(JSON.stringify([process.platform === "win32" ? root.toLowerCase() : root,
|
|
68
|
+
scope.region, scope.principalId, scope.delegateId, scope.adapterId]));
|
|
69
|
+
return { scopeHash, directory: path.join(root, "native-origins", scopeHash) };
|
|
70
|
+
}
|
|
71
|
+
const location = nativeReplyRouteLocation;
|
|
72
|
+
function validate(record, scopeHash, id) {
|
|
73
|
+
if (record?.version !== 1 || record.scopeHash !== scopeHash || record.clientMessageId !== id
|
|
74
|
+
|| !valid(id) || !valid(record.threadId)
|
|
75
|
+
|| (!record.conversationKey && !record.peerDelegateId)
|
|
76
|
+
|| (record.conversationKey !== null && !valid(record.conversationKey))
|
|
77
|
+
|| (record.peerDelegateId !== null && !valid(record.peerDelegateId))
|
|
78
|
+
|| Object.keys(record).sort().join() !== "clientMessageId,conversationKey,peerDelegateId,scopeHash,threadId,version") fail();
|
|
79
|
+
}
|
|
80
|
+
async function readRecord(file, scopeHash, id) {
|
|
81
|
+
const stat = await fs.lstat(file);
|
|
82
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192) fail();
|
|
83
|
+
const record = JSON.parse(await fs.readFile(file, "utf8"));
|
|
84
|
+
validate(record, scopeHash, id);
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
async function safeDirectory(value) {
|
|
88
|
+
let current = path.resolve(value);
|
|
89
|
+
while (true) {
|
|
90
|
+
const stat = await fs.lstat(current);
|
|
91
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail();
|
|
92
|
+
const parent = path.dirname(current);
|
|
93
|
+
if (current === parent) return;
|
|
94
|
+
current = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { createHash, randomUUID } = require("node:crypto");
|
|
6
|
+
const STORE_KEY = "hellolynk.nativeTasks.v1";
|
|
7
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,198}$/;
|
|
8
|
+
const KEY = /^[a-f0-9]{64}$/;
|
|
9
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
10
|
+
const STATUS = /^[a-z][a-z0-9_]{0,79}$/;
|
|
11
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
const object = value => value && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
async function createNativeTaskIndex(scope) {
|
|
15
|
+
const cursor = scope?.adapterId === "cursor";
|
|
16
|
+
const prefix = cursor ? "cursor_native_task" : "workbuddy_native";
|
|
17
|
+
const FORMAT = `hellolynk-${scope?.adapterId}-native-task-index-v1`;
|
|
18
|
+
const failure = () => Object.assign(new Error(`${prefix}_state_invalid`), { code: `${prefix}_state_invalid` });
|
|
19
|
+
if (!scope || !["cursor", "workbuddy"].includes(scope.adapterId) || typeof scope.runtimeRoot !== "string" || !path.isAbsolute(scope.runtimeRoot)
|
|
20
|
+
|| [scope.region, scope.principalId, scope.delegateId].some(value => typeof value !== "string" || !ID.test(value))) {
|
|
21
|
+
throw Object.assign(new Error(`${prefix}_scope_invalid`), { code: `${prefix}_scope_invalid` });
|
|
22
|
+
}
|
|
23
|
+
async function safeDirectory(target) {
|
|
24
|
+
for (let current = path.resolve(target); ; current = path.dirname(current)) {
|
|
25
|
+
const stat = await fs.lstat(current).catch(error => { if (error.code === "ENOENT") return null; throw error; });
|
|
26
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw failure();
|
|
27
|
+
if (path.dirname(current) === current) return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function safeFile(filename) {
|
|
32
|
+
const stat = await fs.lstat(filename).catch(error => { if (error.code === "ENOENT") return null; throw error; });
|
|
33
|
+
if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)) throw failure();
|
|
34
|
+
return stat;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function atomicWrite(filename, content) {
|
|
38
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
39
|
+
let handle;
|
|
40
|
+
try {
|
|
41
|
+
handle = await fs.open(temporary, "wx", 0o600);
|
|
42
|
+
await handle.writeFile(content);
|
|
43
|
+
await handle.sync();
|
|
44
|
+
await handle.close(); handle = null;
|
|
45
|
+
await fs.rename(temporary, filename);
|
|
46
|
+
} finally {
|
|
47
|
+
await handle?.close();
|
|
48
|
+
await fs.rm(temporary, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function* fileChunks(filename) {
|
|
53
|
+
const before = await safeFile(filename);
|
|
54
|
+
if (!before) throw failure();
|
|
55
|
+
const handle = await fs.open(filename, "r");
|
|
56
|
+
try {
|
|
57
|
+
const opened = await handle.stat();
|
|
58
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino) throw failure();
|
|
59
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
60
|
+
while (true) {
|
|
61
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
62
|
+
if (!bytesRead) break;
|
|
63
|
+
yield buffer.subarray(0, bytesRead);
|
|
64
|
+
}
|
|
65
|
+
const after = await handle.stat();
|
|
66
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw failure();
|
|
67
|
+
} finally { await handle.close(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fileHash(filename) {
|
|
71
|
+
const digest = createHash("sha256");
|
|
72
|
+
for await (const chunk of fileChunks(filename)) digest.update(chunk);
|
|
73
|
+
return digest.digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function backupLegacy(filename, target, expectedHash) {
|
|
77
|
+
if (await safeFile(target)) {
|
|
78
|
+
if (await fileHash(target) !== expectedHash) throw failure();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
82
|
+
let handle;
|
|
83
|
+
try {
|
|
84
|
+
handle = await fs.open(temporary, "wx", 0o600);
|
|
85
|
+
const digest = createHash("sha256");
|
|
86
|
+
for await (const chunk of fileChunks(filename)) { digest.update(chunk); await handle.writeFile(chunk); }
|
|
87
|
+
if (digest.digest("hex") !== expectedHash || await safeFile(target)) throw failure();
|
|
88
|
+
await handle.sync(); await handle.close(); handle = null;
|
|
89
|
+
await fs.rename(temporary, target);
|
|
90
|
+
} finally { await handle?.close(); await fs.rm(temporary, { force: true }); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The sole supported import is the current installed JSON shape. Token size,
|
|
94
|
+
// nesting and fields stay bounded; the total history is streamed into SQLite.
|
|
95
|
+
// A large or malformed field cannot bypass the previous bounded-input defense.
|
|
96
|
+
async function importLegacy(db, filename) {
|
|
97
|
+
const digest = createHash("sha256");
|
|
98
|
+
async function* tokens() {
|
|
99
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
100
|
+
let quoted = false, escaped = false, raw = "";
|
|
101
|
+
for await (const chunk of fileChunks(filename)) {
|
|
102
|
+
digest.update(chunk);
|
|
103
|
+
for (const char of decoder.decode(chunk, { stream: true })) {
|
|
104
|
+
if (quoted) {
|
|
105
|
+
raw += char;
|
|
106
|
+
if (raw.length > 4096) throw failure();
|
|
107
|
+
if (escaped) escaped = false;
|
|
108
|
+
else if (char === "\\") escaped = true;
|
|
109
|
+
else if (char === '"') { quoted = false; yield { string: JSON.parse(raw) }; raw = ""; }
|
|
110
|
+
} else if (char === '"') { quoted = true; raw = char; }
|
|
111
|
+
else if ("{}:,".includes(char)) yield char;
|
|
112
|
+
else if (!/[ \r\n\t]/.test(char)) throw failure();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (decoder.decode() || quoted) throw failure();
|
|
116
|
+
}
|
|
117
|
+
const iterator = tokens()[Symbol.asyncIterator]();
|
|
118
|
+
let token;
|
|
119
|
+
const next = async () => { token = await iterator.next(); };
|
|
120
|
+
const expect = async value => { if (token.done || token.value !== value) throw failure(); await next(); };
|
|
121
|
+
const string = async () => {
|
|
122
|
+
if (token.done || !object(token.value) || typeof token.value.string !== "string") throw failure();
|
|
123
|
+
const value = token.value.string; await next(); return value;
|
|
124
|
+
};
|
|
125
|
+
const dictionary = async visit => {
|
|
126
|
+
await expect("{");
|
|
127
|
+
if (token.value === "}") { await next(); return; }
|
|
128
|
+
while (true) {
|
|
129
|
+
const key = await string(); await expect(":"); await visit(key);
|
|
130
|
+
if (token.value === "}") { await next(); return; }
|
|
131
|
+
await expect(",");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
db.exec("CREATE TEMP TABLE import_deliveries (id TEXT PRIMARY KEY, status TEXT, session TEXT) WITHOUT ROWID");
|
|
135
|
+
const existing = db.prepare("SELECT status, session FROM import_deliveries WHERE id = ?");
|
|
136
|
+
const status = db.prepare("INSERT INTO import_deliveries (id,status) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status");
|
|
137
|
+
const session = db.prepare("INSERT INTO import_deliveries (id,session) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET session=excluded.session");
|
|
138
|
+
const putCurrent = db.prepare("INSERT INTO conversations VALUES (?, ?, ?, ?)");
|
|
139
|
+
const roots = new Set();
|
|
140
|
+
try {
|
|
141
|
+
await next();
|
|
142
|
+
const conversations = async () => dictionary(async conversation => {
|
|
143
|
+
if (!KEY.test(conversation)) throw failure();
|
|
144
|
+
const record = {}, fields = new Set();
|
|
145
|
+
await dictionary(async field => {
|
|
146
|
+
if (fields.has(field)) throw failure(); fields.add(field);
|
|
147
|
+
if ((cursor ? ["nativeSessionId", "localMessageId", "status"] : ["nativeSessionId", "createState"]).includes(field)) record[field] = await string();
|
|
148
|
+
else if (field === "deliveries" || field === "messageSessions") {
|
|
149
|
+
await dictionary(async id => {
|
|
150
|
+
const value = await string(), row = existing.get(id);
|
|
151
|
+
if (!ID.test(id)) throw failure();
|
|
152
|
+
if (field === "deliveries") {
|
|
153
|
+
if (!STATUS.test(value) || row?.status != null) throw failure(); status.run(id, value);
|
|
154
|
+
} else {
|
|
155
|
+
if (!UUID.test(value) || row?.session != null) throw failure(); session.run(id, value);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
} else throw failure();
|
|
159
|
+
});
|
|
160
|
+
if (!cursor) {
|
|
161
|
+
if (!["creating", "created", "create_unknown"].includes(record.createState)) throw failure();
|
|
162
|
+
record.status = record.createState; record.localMessageId = "";
|
|
163
|
+
}
|
|
164
|
+
validateRecord(conversation, record);
|
|
165
|
+
if (cursor) status.run(record.localMessageId, record.status);
|
|
166
|
+
if (db.prepare("SELECT 1 FROM import_deliveries WHERE status IS NULL LIMIT 1").get()) throw failure();
|
|
167
|
+
putCurrent.run(conversation, record.nativeSessionId, record.localMessageId, record.status);
|
|
168
|
+
db.prepare("INSERT INTO deliveries SELECT ?, id, coalesce(session, ?), status FROM import_deliveries").run(conversation, record.nativeSessionId);
|
|
169
|
+
db.exec("DELETE FROM import_deliveries");
|
|
170
|
+
});
|
|
171
|
+
if (cursor) await dictionary(async root => {
|
|
172
|
+
if (root !== STORE_KEY || roots.has(root)) throw failure(); roots.add(root);
|
|
173
|
+
await conversations();
|
|
174
|
+
});
|
|
175
|
+
else await conversations();
|
|
176
|
+
if (!token.done) throw failure();
|
|
177
|
+
return digest.digest("hex");
|
|
178
|
+
} finally { await iterator.return?.(); db.exec("DROP TABLE import_deliveries"); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validateRecord(key, record) {
|
|
182
|
+
if (!KEY.test(key) || !object(record) || !UUID.test(record.nativeSessionId || "")
|
|
183
|
+
|| (cursor ? !ID.test(record.localMessageId || "") : record.localMessageId !== "")
|
|
184
|
+
|| (cursor ? !STATUS.test(record.status || "") : !["creating", "created", "create_unknown"].includes(record.status))) throw failure();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const key = hash(JSON.stringify([scope.adapterId, scope.region, scope.principalId, scope.delegateId]));
|
|
188
|
+
const directory = path.join(scope.runtimeRoot, "native-tasks");
|
|
189
|
+
await safeDirectory(directory);
|
|
190
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
191
|
+
await safeDirectory(directory);
|
|
192
|
+
const filename = path.join(directory, `${key}.json`);
|
|
193
|
+
const database = path.join(directory, `${key}.sqlite`);
|
|
194
|
+
// An array deliberately fails the previous reader's object-only validation.
|
|
195
|
+
// A code rollback must not silently ignore new delivery evidence and replay it.
|
|
196
|
+
const marker = JSON.stringify([FORMAT, path.basename(database), key]);
|
|
197
|
+
const originalStat = await safeFile(filename);
|
|
198
|
+
const indexed = originalStat && originalStat.size <= 1024 && (await fs.readFile(filename, "utf8")).trim() === marker;
|
|
199
|
+
for (const suffix of ["", "-journal", "-wal", "-shm"]) await safeFile(`${database}${suffix}`);
|
|
200
|
+
if (!await safeFile(database)) {
|
|
201
|
+
if (indexed) throw failure(); // A missing index never means an empty history.
|
|
202
|
+
const handle = await fs.open(database, "wx", 0o600);
|
|
203
|
+
await handle.close();
|
|
204
|
+
}
|
|
205
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = new DatabaseSync(database);
|
|
209
|
+
db.exec("PRAGMA busy_timeout=0; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF;");
|
|
210
|
+
let sourceHash = "absent";
|
|
211
|
+
const empty = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length === 0;
|
|
212
|
+
if (empty) {
|
|
213
|
+
if (indexed) throw failure();
|
|
214
|
+
db.exec("BEGIN IMMEDIATE");
|
|
215
|
+
try {
|
|
216
|
+
db.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID;
|
|
217
|
+
CREATE TABLE conversations (key TEXT PRIMARY KEY, nativeSessionId TEXT NOT NULL, localMessageId TEXT NOT NULL, status TEXT NOT NULL) WITHOUT ROWID;
|
|
218
|
+
CREATE INDEX conversations_session ON conversations(nativeSessionId);
|
|
219
|
+
CREATE TABLE deliveries (conversationKey TEXT NOT NULL, messageId TEXT NOT NULL, nativeSessionId TEXT NOT NULL, status TEXT NOT NULL,
|
|
220
|
+
PRIMARY KEY(conversationKey, messageId)) WITHOUT ROWID;`);
|
|
221
|
+
if (originalStat) sourceHash = await importLegacy(db, filename);
|
|
222
|
+
const meta = db.prepare("INSERT INTO metadata VALUES (?, ?)");
|
|
223
|
+
for (const [name, value] of [["format", FORMAT], ["scope", key], ["sourceHash", sourceHash]]) meta.run(name, value);
|
|
224
|
+
db.exec("COMMIT");
|
|
225
|
+
} catch (error) { db.exec("ROLLBACK"); throw error; }
|
|
226
|
+
}
|
|
227
|
+
else if (!indexed && originalStat) sourceHash = await fileHash(filename);
|
|
228
|
+
const meta = Object.fromEntries(db.prepare("SELECT key, value FROM metadata").all().map(row => [row.key, row.value]));
|
|
229
|
+
if (meta.format !== FORMAT || meta.scope !== key || !indexed && meta.sourceHash !== sourceHash) throw failure();
|
|
230
|
+
db.prepare("SELECT key, nativeSessionId, localMessageId, status FROM conversations LIMIT 0").all();
|
|
231
|
+
db.prepare("SELECT conversationKey, messageId, nativeSessionId, status FROM deliveries LIMIT 0").all();
|
|
232
|
+
if (!indexed) {
|
|
233
|
+
// Import the current installed JSON once; retain its exact bytes as the
|
|
234
|
+
// recovery source. An interrupted prepared database is reused only when
|
|
235
|
+
// this source is still identical. No downgrade or state overwrite occurs.
|
|
236
|
+
if (originalStat) await backupLegacy(filename, `${filename}.before-indexed`, sourceHash);
|
|
237
|
+
await safeDirectory(directory);
|
|
238
|
+
const latestHash = await safeFile(filename) ? await fileHash(filename) : "absent";
|
|
239
|
+
if (latestHash !== sourceHash) throw failure();
|
|
240
|
+
await atomicWrite(filename, `${marker}\n`);
|
|
241
|
+
}
|
|
242
|
+
// Validate the required schema before returning a usable dispatcher state.
|
|
243
|
+
const current = db.prepare("SELECT nativeSessionId, localMessageId, status FROM conversations WHERE key = ?");
|
|
244
|
+
const delivery = db.prepare("SELECT nativeSessionId, status FROM deliveries WHERE conversationKey = ? AND messageId = ?");
|
|
245
|
+
const owned = db.prepare("SELECT 1 FROM conversations WHERE nativeSessionId = ? AND (? IS NULL OR status = ?) LIMIT 1");
|
|
246
|
+
const sessions = db.prepare("SELECT DISTINCT nativeSessionId FROM conversations WHERE status = ?");
|
|
247
|
+
const putCurrent = db.prepare("INSERT INTO conversations VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET nativeSessionId=excluded.nativeSessionId, localMessageId=excluded.localMessageId, status=excluded.status");
|
|
248
|
+
const putDelivery = db.prepare("INSERT INTO deliveries VALUES (?, ?, ?, ?) ON CONFLICT(conversationKey, messageId) DO UPDATE SET nativeSessionId=excluded.nativeSessionId, status=excluded.status");
|
|
249
|
+
const deleteCurrent = db.prepare("DELETE FROM conversations WHERE key = ?");
|
|
250
|
+
const deleteDelivery = db.prepare("DELETE FROM deliveries WHERE conversationKey = ? AND messageId = ?");
|
|
251
|
+
const identity = await safeFile(database);
|
|
252
|
+
let closed = false;
|
|
253
|
+
const assertOpen = () => { if (closed) throw failure(); };
|
|
254
|
+
const transaction = fn => {
|
|
255
|
+
assertOpen(); db.exec("BEGIN IMMEDIATE");
|
|
256
|
+
try { const result = fn(); db.exec("COMMIT"); return result; }
|
|
257
|
+
catch (error) {
|
|
258
|
+
try { db.exec("ROLLBACK"); } catch { closed = true; db.close(); }
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
async function writable() {
|
|
263
|
+
assertOpen(); await safeDirectory(directory);
|
|
264
|
+
const stat = await safeFile(database);
|
|
265
|
+
const markerStat = await safeFile(filename);
|
|
266
|
+
if (!stat || stat.dev !== identity.dev || stat.ino !== identity.ino || !markerStat || markerStat.size > 1024
|
|
267
|
+
|| (await fs.readFile(filename, "utf8")).trim() !== marker) throw failure();
|
|
268
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) await safeFile(`${database}${suffix}`);
|
|
269
|
+
}
|
|
270
|
+
const recordFrom = row => row ? { ...row } : undefined;
|
|
271
|
+
return Object.freeze({
|
|
272
|
+
getConversation(conversation) { assertOpen(); if (!KEY.test(conversation)) throw failure(); return recordFrom(current.get(conversation)); },
|
|
273
|
+
getDelivery(conversation, id) { assertOpen(); if (!KEY.test(conversation) || !ID.test(id)) throw failure(); return recordFrom(delivery.get(conversation, id)); },
|
|
274
|
+
ownsSession(id, status = null) { assertOpen(); return UUID.test(id || "") && Boolean(owned.get(id, status, status)); },
|
|
275
|
+
listSessions(status) { assertOpen(); if (!STATUS.test(status || "")) throw failure(); return sessions.all(status).map(row => row.nativeSessionId); },
|
|
276
|
+
async save(conversation, record, id = null, status = null) {
|
|
277
|
+
validateRecord(conversation, record);
|
|
278
|
+
if (id !== null && (!ID.test(id) || !STATUS.test(status || ""))) throw failure();
|
|
279
|
+
await writable();
|
|
280
|
+
transaction(() => {
|
|
281
|
+
const previous = id === null ? null : delivery.get(conversation, id);
|
|
282
|
+
if (previous && previous.nativeSessionId !== record.nativeSessionId) throw failure();
|
|
283
|
+
putCurrent.run(conversation, record.nativeSessionId, record.localMessageId, record.status);
|
|
284
|
+
if (id !== null) putDelivery.run(conversation, id, record.nativeSessionId, status);
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
async rollbackUnsubmitted(conversation, sessionId, previous, id = null) {
|
|
288
|
+
if (!KEY.test(conversation) || !UUID.test(sessionId) || id !== null && !ID.test(id)) throw failure();
|
|
289
|
+
if (previous) validateRecord(conversation, previous);
|
|
290
|
+
await writable();
|
|
291
|
+
return transaction(() => {
|
|
292
|
+
const record = current.get(conversation), attempt = id === null ? null : delivery.get(conversation, id);
|
|
293
|
+
if (record?.nativeSessionId !== sessionId || (id === null ? record.status !== "creating" : attempt?.nativeSessionId !== sessionId || attempt.status !== "dispatching")) return false;
|
|
294
|
+
if (previous) putCurrent.run(conversation, previous.nativeSessionId, previous.localMessageId, previous.status);
|
|
295
|
+
else deleteCurrent.run(conversation);
|
|
296
|
+
if (id !== null) deleteDelivery.run(conversation, id);
|
|
297
|
+
return true;
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
close() { if (!closed) { closed = true; db.close(); } },
|
|
301
|
+
});
|
|
302
|
+
} catch (error) { db?.close(); throw failure(); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = Object.freeze({ createNativeTaskIndex });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { withHelloLynkLocalMutationLock } from "./hellolynk-local-messaging.mjs";
|
|
7
|
+
|
|
8
|
+
// A live OS handle elects one receiver for a client/account. It has no expiry:
|
|
9
|
+
// slow model work must never let a second receiver take over. Process death
|
|
10
|
+
// closes the handle. Only election itself takes the short filesystem lock.
|
|
11
|
+
export async function tryAcquireNativeWorkerLease(scope) {
|
|
12
|
+
const { runtimeRoot, adapterId, principalId, delegateId, region } = scope || {};
|
|
13
|
+
if (typeof runtimeRoot !== "string" || !path.isAbsolute(runtimeRoot)
|
|
14
|
+
|| [adapterId, principalId, delegateId, region].some((value) =>
|
|
15
|
+
typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,240}$/.test(value))) {
|
|
16
|
+
throw failure("native_worker_scope_invalid");
|
|
17
|
+
}
|
|
18
|
+
const root = process.platform === "win32" ? path.resolve(runtimeRoot).toLowerCase() : path.resolve(runtimeRoot);
|
|
19
|
+
const key = crypto.createHash("sha256").update(JSON.stringify([root, adapterId, principalId, delegateId, region])).digest("hex");
|
|
20
|
+
const guard = path.join(runtimeRoot, "native-workers", key);
|
|
21
|
+
let endpoint;
|
|
22
|
+
if (process.platform === "win32") endpoint = `\\\\.\\pipe\\hellolynk-native-${key}`;
|
|
23
|
+
else {
|
|
24
|
+
const directory = path.join(os.tmpdir(), `hellolynk-native-${process.getuid()}`);
|
|
25
|
+
await fs.mkdir(directory, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
26
|
+
const stat = await fs.lstat(directory);
|
|
27
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
|
|
28
|
+
throw failure("native_worker_socket_directory_unsafe");
|
|
29
|
+
}
|
|
30
|
+
endpoint = path.join(directory, `${key.slice(0, 40)}.sock`);
|
|
31
|
+
}
|
|
32
|
+
return withHelloLynkLocalMutationLock(guard, async () => {
|
|
33
|
+
let server = await listen(endpoint);
|
|
34
|
+
if (!server && process.platform !== "win32" && await isStaleSocket(endpoint)) {
|
|
35
|
+
// Every acquisition/release uses the same guard, so a different worker
|
|
36
|
+
// cannot bind between the liveness check and removing the stale socket.
|
|
37
|
+
await fs.unlink(endpoint).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
38
|
+
server = await listen(endpoint);
|
|
39
|
+
}
|
|
40
|
+
if (!server) return null;
|
|
41
|
+
let releaseFlight;
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
release() {
|
|
44
|
+
if (!releaseFlight) releaseFlight = withHelloLynkLocalMutationLock(guard, () =>
|
|
45
|
+
new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())));
|
|
46
|
+
return releaseFlight;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function listen(endpoint) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = net.createServer((socket) => socket.destroy());
|
|
55
|
+
server.once("error", (error) => {
|
|
56
|
+
if (error.code === "EADDRINUSE") resolve(null);
|
|
57
|
+
else reject(error);
|
|
58
|
+
});
|
|
59
|
+
server.listen({ path: endpoint, exclusive: true }, () => {
|
|
60
|
+
server.unref();
|
|
61
|
+
resolve(server);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function isStaleSocket(endpoint) {
|
|
67
|
+
const stat = await fs.lstat(endpoint).catch((error) => {
|
|
68
|
+
if (error.code === "ENOENT") return null;
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
if (!stat) return true;
|
|
72
|
+
if (!stat.isSocket() || stat.isSymbolicLink() || stat.uid !== process.getuid()) throw failure("native_worker_socket_unsafe");
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const socket = net.createConnection(endpoint);
|
|
75
|
+
socket.once("connect", () => { socket.destroy(); resolve(false); });
|
|
76
|
+
socket.once("error", (error) => {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
if (["ECONNREFUSED", "ENOENT"].includes(error.code)) resolve(true);
|
|
79
|
+
else reject(error);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function failure(code) { return Object.assign(new Error(code), { code }); }
|