@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { lstat } from "node:fs/promises";
|
|
4
|
+
import { normalizeHelloLynkBaseUrl } from "./hellolynk-client-core.mjs";
|
|
5
|
+
import { createPlatformCredentialStore, assertCredentialProfileId, prepareWindowsClientRoot } from "./hellolynk-platform-credential-store.mjs";
|
|
6
|
+
import { defaultHelloLynkClientRoot, readClientProfile, writeClientProfile } from "./hellolynk-client-profile.mjs";
|
|
7
|
+
import { createNeutralHostInstallationStore, defaultNeutralHostStoreRoot, writeNeutralHostProfileManifest } from "./hellolynk-neutral-host-installation-store.mjs";
|
|
8
|
+
import { createHostProtectedStore } from "./hellolynk-host-protected-store.mjs";
|
|
9
|
+
import runtimeSafety from "./hellolynk-runtime-safety.cjs";
|
|
10
|
+
|
|
11
|
+
const CLIENT_TYPES = new Set(["codex", "openclaw", "trae-work", "workbuddy", "cursor", "dsh", "claude-code"]);
|
|
12
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/;
|
|
13
|
+
const REQUEST = /^devreq_[a-f0-9]{48}$/;
|
|
14
|
+
const PROOF = /^[A-Za-z0-9_-]{43}$/;
|
|
15
|
+
const DEVICE = /^device_[a-f0-9]{48}$/;
|
|
16
|
+
const INTENT = /^reg_[a-f0-9]{48}$/;
|
|
17
|
+
const TRANSPORT = /^hellolynk_host_transport_v1_([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]{32,}$/;
|
|
18
|
+
const CREDENTIAL = /^hellolynk_device_v1_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{32,}$/;
|
|
19
|
+
const SCOPES = new Set(["relay.text.send", "relay.text.receive", "runtime.session.refresh", "runtime.session.revoke"]);
|
|
20
|
+
|
|
21
|
+
function fail(code) { return Object.assign(new Error(code), { code }); }
|
|
22
|
+
function checkedId(value) {
|
|
23
|
+
if (typeof value !== "string" || !ID.test(value)) throw fail("device_enrollment_response_invalid");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function label(value, code) {
|
|
27
|
+
if (typeof value !== "string" || !value.trim() || [...value.trim()].length > 80
|
|
28
|
+
|| /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(value)) throw fail(code);
|
|
29
|
+
return value.trim();
|
|
30
|
+
}
|
|
31
|
+
function newInstanceName(value, code) {
|
|
32
|
+
if (typeof value !== "string" || [...value].length < 1 || [...value].length > 40
|
|
33
|
+
|| !/^[\p{Script=Han}A-Za-z0-9_-]+$/u.test(value)) throw fail(code);
|
|
34
|
+
return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
37
|
+
function canonicalDateTime(value) {
|
|
38
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw fail("device_enrollment_response_invalid");
|
|
39
|
+
return new Date(Date.parse(value)).toISOString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only this local coordinator handles enrollment proofs and credentials. Its
|
|
43
|
+
// public result is deliberately not the server's claim/registration response.
|
|
44
|
+
export function createDeviceEnrollmentClient(options = {}, dependencies = {}) {
|
|
45
|
+
const root = resolve(options.root || defaultHelloLynkClientRoot());
|
|
46
|
+
const hostRoot = resolve(options.hostRoot || defaultNeutralHostStoreRoot());
|
|
47
|
+
const baseUrl = normalizeHelloLynkBaseUrl(options.baseUrl, { allowLoopbackHttp: options.allowLoopbackHttp === true });
|
|
48
|
+
const fetchImpl = dependencies.fetchImpl || runtimeSafety.helloLynkNetworkFetch;
|
|
49
|
+
const installationStore = dependencies.installationStore || createNeutralHostInstallationStore({ root: hostRoot });
|
|
50
|
+
const protectedStore = dependencies.protectedStore || createHostProtectedStore({ root: hostRoot, installationStore });
|
|
51
|
+
const credentialStore = dependencies.credentialStore || createPlatformCredentialStore({ storeRoot: join(root, "credentials") });
|
|
52
|
+
const now = dependencies.now || Date.now;
|
|
53
|
+
const random = dependencies.randomBytes || randomBytes;
|
|
54
|
+
const timeoutMs = Math.min(30_000, Math.max(1_000, options.timeoutMs || 10_000));
|
|
55
|
+
|
|
56
|
+
async function request(path, body, credential) {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
61
|
+
method: "POST", redirect: "error", credentials: "omit", cache: "no-store",
|
|
62
|
+
headers: { "content-type": "application/json", ...(credential ? { "x-hellolynk-host-transport-credential": credential } : {}) },
|
|
63
|
+
body: JSON.stringify(body), signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
const reader = response.body?.getReader();
|
|
66
|
+
if (!reader) throw fail("device_enrollment_response_invalid");
|
|
67
|
+
const chunks = []; let size = 0;
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const chunk = await reader.read();
|
|
71
|
+
if (chunk.done) break;
|
|
72
|
+
size += chunk.value.byteLength;
|
|
73
|
+
if (size > 64 * 1024) throw fail("device_enrollment_response_invalid");
|
|
74
|
+
chunks.push(Buffer.from(chunk.value));
|
|
75
|
+
}
|
|
76
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
77
|
+
let result;
|
|
78
|
+
try { result = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
79
|
+
catch { throw fail("device_enrollment_response_invalid"); }
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const code = typeof result?.error === "string" && /^[a-z][a-z0-9_]{0,95}$/.test(result.error)
|
|
82
|
+
? result.error : "device_enrollment_request_failed";
|
|
83
|
+
throw Object.assign(fail(code), { status: response.status });
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code && /^[a-z][a-z0-9_]{0,95}$/.test(error.code)) throw error;
|
|
88
|
+
throw fail(controller.signal.aborted ? "device_enrollment_request_timeout"
|
|
89
|
+
: runtimeSafety.helloLynkNetworkErrorCode(error, "device_enrollment_request_failed"));
|
|
90
|
+
} finally { clearTimeout(timer); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateState(value) {
|
|
94
|
+
if (value === null) return null;
|
|
95
|
+
if (value?.baseUrl !== baseUrl || !/^host_[A-Za-z0-9_-]{32}$/.test(value?.hostId || "")
|
|
96
|
+
|| !runtimeSafety.classifyHelloLynkStableId(value?.hellolynkId)
|
|
97
|
+
|| !REQUEST.test(value?.requestId || "") || !PROOF.test(value?.proof || "")
|
|
98
|
+
|| !value.intents || typeof value.intents !== "object" || Array.isArray(value.intents)
|
|
99
|
+
|| Object.keys(value.intents).length > 32) throw fail("device_enrollment_owner_conflict");
|
|
100
|
+
label(value.deviceLabel, "device_enrollment_store_invalid");
|
|
101
|
+
const normalized = { ...value, approval: value.approval === null ? null : validateApproval(value.approval, value), intents: {} };
|
|
102
|
+
for (const [profileId, intent] of Object.entries(value.intents)) {
|
|
103
|
+
assertCredentialProfileId(profileId);
|
|
104
|
+
if (!CLIENT_TYPES.has(intent?.clientType) || !INTENT.test(intent?.registrationIntentId || "")
|
|
105
|
+
|| !DEVICE.test(intent?.deviceId || "")) throw fail("device_enrollment_store_invalid");
|
|
106
|
+
if (intent.requestedName !== null) label(intent.requestedName, "device_enrollment_store_invalid");
|
|
107
|
+
if (intent.previousRequestedNames !== undefined) {
|
|
108
|
+
if (!Array.isArray(intent.previousRequestedNames)) throw fail("device_enrollment_store_invalid");
|
|
109
|
+
for (const name of intent.previousRequestedNames) {
|
|
110
|
+
if (name !== null) label(name, "device_enrollment_store_invalid");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
normalized.intents[profileId] = { ...intent,
|
|
114
|
+
binding: intent.binding === null ? null : validateBinding(intent.binding, normalized, intent) };
|
|
115
|
+
}
|
|
116
|
+
return normalized;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function load() {
|
|
120
|
+
const record = await protectedStore.loadDeviceEnrollment();
|
|
121
|
+
return { ...record, value: validateState(record.value) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function update(mutator) {
|
|
125
|
+
// CAS protects two explicit client setups racing on the same computer.
|
|
126
|
+
// The callback is local only: HTTP must never run inside a CAS retry.
|
|
127
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
128
|
+
const before = await load();
|
|
129
|
+
const next = mutator(before.value && clone(before.value));
|
|
130
|
+
if (next === undefined) return before.value;
|
|
131
|
+
const normalized = validateState(next);
|
|
132
|
+
try {
|
|
133
|
+
// Native JSON serialization may omit insignificant fractional zeros.
|
|
134
|
+
// Canonicalize the returned state as well as reads and HTTP responses
|
|
135
|
+
// before comparing a retried registration with its durable result.
|
|
136
|
+
return validateState((await protectedStore.saveDeviceEnrollment({ expectedRevision: before.revision, value: normalized })).value);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error?.code !== "device_enrollment_revision_conflict") throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw fail("device_enrollment_busy");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateApproval(result, state) {
|
|
145
|
+
const transport = result?.hostTransportCredential;
|
|
146
|
+
const match = TRANSPORT.exec(transport?.credential || "");
|
|
147
|
+
const identity = runtimeSafety.classifyHelloLynkStableId(state.hellolynkId);
|
|
148
|
+
if (result?.status !== "approved" || result.hostId !== state.hostId
|
|
149
|
+
|| result.hellolynkId !== state.hellolynkId || result.region !== identity.region
|
|
150
|
+
|| !match || match[1] !== transport.credentialId
|
|
151
|
+
|| !Number.isFinite(Date.parse(transport.createdAt)) || !Number.isFinite(Date.parse(transport.expiresAt))
|
|
152
|
+
|| Date.parse(transport.expiresAt) <= Date.parse(transport.createdAt)) throw fail("device_enrollment_response_invalid");
|
|
153
|
+
checkedId(result.principalId);
|
|
154
|
+
return {
|
|
155
|
+
status: "approved", principalId: result.principalId, hellolynkId: state.hellolynkId,
|
|
156
|
+
region: result.region, hostId: state.hostId,
|
|
157
|
+
hostTransportCredential: { credentialId: transport.credentialId, credential: transport.credential,
|
|
158
|
+
createdAt: canonicalDateTime(transport.createdAt), expiresAt: canonicalDateTime(transport.expiresAt) },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateBinding(result, state, intent) {
|
|
163
|
+
const b = result?.binding; const i = result?.instance;
|
|
164
|
+
if (!state.approval || b?.principalId !== state.approval.principalId
|
|
165
|
+
|| b.hellolynkId !== state.hellolynkId || b.hostId !== state.hostId
|
|
166
|
+
|| b.region !== state.approval.region || b.deviceId !== intent.deviceId
|
|
167
|
+
|| !CREDENTIAL.test(b.deviceCredential || "") || i?.delegateId !== b.delegateId
|
|
168
|
+
|| i.agentId !== b.agentId || i.ownerHostId !== state.hostId || i.deviceId !== intent.deviceId
|
|
169
|
+
|| i.clientType !== intent.clientType || !Number.isSafeInteger(i.authorizationVersion) || i.authorizationVersion < 1
|
|
170
|
+
|| !Array.isArray(b.allowedScopes) || !b.allowedScopes.length
|
|
171
|
+
|| b.allowedScopes.some((scope) => !SCOPES.has(scope))) throw fail("device_enrollment_response_invalid");
|
|
172
|
+
for (const id of [b.delegateId, b.agentId, b.credentialId]) checkedId(id);
|
|
173
|
+
label(i.displayName, "device_enrollment_response_invalid");
|
|
174
|
+
if (intent.requestedName !== null && i.displayName !== intent.requestedName) throw fail("device_enrollment_response_invalid");
|
|
175
|
+
return {
|
|
176
|
+
instance: { delegateId: i.delegateId, agentId: i.agentId, displayName: i.displayName,
|
|
177
|
+
clientType: i.clientType, ownerHostId: i.ownerHostId, deviceId: i.deviceId, authorizationVersion: i.authorizationVersion },
|
|
178
|
+
binding: { principalId: b.principalId, hellolynkId: b.hellolynkId, hostId: b.hostId, region: b.region,
|
|
179
|
+
agentId: b.agentId, delegateId: b.delegateId, deviceId: b.deviceId, credentialId: b.credentialId,
|
|
180
|
+
deviceCredential: b.deviceCredential, allowedScopes: [...b.allowedScopes],
|
|
181
|
+
expiresAt: b.expiresAt === null ? null : canonicalDateTime(b.expiresAt) },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function publicPending(state, expiresAt = null) {
|
|
186
|
+
return { ok: true, status: "confirmation_required", hellolynkId: state.hellolynkId,
|
|
187
|
+
verificationUrl: `${baseUrl}/account?deviceRequest=${encodeURIComponent(state.requestId)}`, expiresAt };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function nameRejected(error) {
|
|
191
|
+
return (error?.status === 400 && error.code === "agent_instance_name_invalid")
|
|
192
|
+
|| (error?.status === 409 && error.code === "agent_instance_name_conflict");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function registerIntent(state, intent) {
|
|
196
|
+
const response = await request("/api/v2/host-transport/instances", {
|
|
197
|
+
registrationIntentId: intent.registrationIntentId, clientType: intent.clientType, deviceId: intent.deviceId,
|
|
198
|
+
...(intent.requestedName !== null ? { requestedName: intent.requestedName } : {}),
|
|
199
|
+
}, state.approval.hostTransportCredential.credential);
|
|
200
|
+
return { registered: validateBinding(response, state, intent), intent };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function recoverRegistration(state, intent, profileId) {
|
|
204
|
+
try { return await registerIntent(state, intent); }
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error?.status !== 409 || error.code !== "agent_instance_intent_conflict") throw error;
|
|
207
|
+
const latest = (await load()).value?.intents[profileId];
|
|
208
|
+
if (!latest || latest.registrationIntentId !== intent.registrationIntentId || latest.deviceId !== intent.deviceId) {
|
|
209
|
+
throw fail("device_enrollment_intent_conflict");
|
|
210
|
+
}
|
|
211
|
+
// Another request may have committed an earlier name after its caller
|
|
212
|
+
// lost the response. Reuse this intent to recover that one receipt.
|
|
213
|
+
const candidates = new Set([latest.requestedName, ...(latest.previousRequestedNames || [])]);
|
|
214
|
+
candidates.delete(intent.requestedName);
|
|
215
|
+
for (const requestedName of candidates) {
|
|
216
|
+
try { return await registerIntent(state, { ...intent, requestedName }); }
|
|
217
|
+
catch (candidateError) {
|
|
218
|
+
if (!nameRejected(candidateError)
|
|
219
|
+
&& !(candidateError?.status === 409 && candidateError.code === "agent_instance_intent_conflict")) throw candidateError;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function connect(input = {}) {
|
|
227
|
+
if (!CLIENT_TYPES.has(input.clientType)) throw fail("device_enrollment_client_unsupported");
|
|
228
|
+
const identity = runtimeSafety.classifyHelloLynkStableId(input.hellolynkId);
|
|
229
|
+
if (!identity) throw fail("device_enrollment_hellolynk_id_invalid");
|
|
230
|
+
const profileId = assertCredentialProfileId(input.profileId || `instance-${input.clientType}`);
|
|
231
|
+
const rawRequestedName = input.requestedName == null ? null : input.requestedName;
|
|
232
|
+
// PrepareRoot creates a trusted new root, but rejects an existing untrusted
|
|
233
|
+
// directory. Creating the Host first would bypass that first-install step.
|
|
234
|
+
if (process.platform === "win32") await (dependencies.prepareWindowsClientRoot || prepareWindowsClientRoot)(root);
|
|
235
|
+
const savedBeforeConnect = (await load()).value;
|
|
236
|
+
const priorIntent = savedBeforeConnect?.intents?.[profileId];
|
|
237
|
+
const requestedName = rawRequestedName === null ? null
|
|
238
|
+
: priorIntent?.requestedName === rawRequestedName
|
|
239
|
+
? rawRequestedName
|
|
240
|
+
: newInstanceName(rawRequestedName, "device_enrollment_name_invalid");
|
|
241
|
+
const installation = await installationStore.createId();
|
|
242
|
+
const hostId = installation.hostInstallationId;
|
|
243
|
+
if (installation.region && !runtimeSafety.helloLynkStableIdMatchesRegion(identity, installation.region)) {
|
|
244
|
+
throw fail("device_enrollment_owner_conflict");
|
|
245
|
+
}
|
|
246
|
+
const configPath = join(root, "profiles", `${profileId}.json`);
|
|
247
|
+
const profileReader = dependencies.readClientProfile || readClientProfile;
|
|
248
|
+
let existingProfile = null;
|
|
249
|
+
try {
|
|
250
|
+
if (!dependencies.readClientProfile) await lstat(configPath);
|
|
251
|
+
existingProfile = await profileReader(configPath);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!["ENOENT", "client_profile_not_found"].includes(error?.code || error?.message)) throw error;
|
|
254
|
+
}
|
|
255
|
+
if (existingProfile) {
|
|
256
|
+
// A package has a fixed profile name. It may resume its own durable
|
|
257
|
+
// enrollment, but must never register a new remote identity over an old
|
|
258
|
+
// installation and discover the conflict only after the server write.
|
|
259
|
+
const savedBinding = savedBeforeConnect?.intents[profileId]?.binding?.binding;
|
|
260
|
+
if (!savedBinding) throw fail("device_enrollment_existing_profile_requires_setup");
|
|
261
|
+
if (existingProfile.profileId !== profileId || existingProfile.baseUrl !== baseUrl
|
|
262
|
+
|| !runtimeSafety.helloLynkStableIdMatchesRegion(identity, existingProfile.region)
|
|
263
|
+
|| ["principalId", "hellolynkId", "delegateId", "agentId", "deviceId", "credentialId", "hostId"]
|
|
264
|
+
.some((field) => existingProfile[field] !== savedBinding[field])) {
|
|
265
|
+
throw fail("device_enrollment_profile_conflict");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let state = await update((current) => {
|
|
269
|
+
if (current) {
|
|
270
|
+
if (current.hostId !== hostId) throw fail("device_enrollment_owner_conflict");
|
|
271
|
+
if (current.hellolynkId === identity.value) return undefined;
|
|
272
|
+
// Only an unclaimed draft may change owner. A saved approval, any AI
|
|
273
|
+
// registration intent or an existing installation still binds identity.
|
|
274
|
+
if (current.approval || Object.keys(current.intents).length || installation.principalId
|
|
275
|
+
|| installation.region || installation.delegateBindings?.length || existingProfile) {
|
|
276
|
+
throw fail("device_enrollment_owner_conflict");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { baseUrl, hellolynkId: identity.value, hostId, deviceLabel: label(input.deviceLabel || "This computer", "device_enrollment_label_invalid"),
|
|
280
|
+
requestId: `devreq_${random(24).toString("hex")}`, proof: random(32).toString("base64url"), approval: null, intents: {} };
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
for (let attempt = 0; !state.approval && attempt < 2; attempt += 1) {
|
|
284
|
+
const requestId = state.requestId;
|
|
285
|
+
try {
|
|
286
|
+
const started = await request("/api/v2/device-authorizations", {
|
|
287
|
+
requestId: state.requestId, hellolynkId: state.hellolynkId, hostId: state.hostId,
|
|
288
|
+
deviceLabel: state.deviceLabel, proofChallenge: createHash("sha256").update(state.proof).digest("hex"),
|
|
289
|
+
});
|
|
290
|
+
if (started?.requestId !== state.requestId || !["pending", "approved"].includes(started.status)
|
|
291
|
+
|| !Number.isFinite(Date.parse(started.expiresAt))) throw fail("device_enrollment_response_invalid");
|
|
292
|
+
const claimed = await request(`/api/v2/device-authorizations/${state.requestId}/claim`, { proof: state.proof });
|
|
293
|
+
if (claimed?.status === "pending") return publicPending(state, started.expiresAt);
|
|
294
|
+
const approval = validateApproval(claimed, state);
|
|
295
|
+
if (installation.principalId && installation.principalId !== approval.principalId) throw fail("device_enrollment_owner_conflict");
|
|
296
|
+
state = await update((current) => {
|
|
297
|
+
if (current.requestId !== requestId) throw fail("device_enrollment_owner_conflict");
|
|
298
|
+
if (current.approval && JSON.stringify(current.approval) !== JSON.stringify(approval)) throw fail("device_enrollment_response_invalid");
|
|
299
|
+
current.approval = approval; return current;
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// A definite expired, unapproved request is safe to replace. Network
|
|
303
|
+
// uncertainty never rotates its proof or discards registration intents.
|
|
304
|
+
if (error?.status !== 410 || error?.code !== "device_authorization_expired" || attempt > 0) throw error;
|
|
305
|
+
state = await update((current) => {
|
|
306
|
+
if (current.requestId !== requestId || current.approval) return undefined;
|
|
307
|
+
current.requestId = `devreq_${random(24).toString("hex")}`;
|
|
308
|
+
current.proof = random(32).toString("base64url");
|
|
309
|
+
return current;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (Date.parse(state.approval.hostTransportCredential.expiresAt) <= now()) throw fail("device_enrollment_authorization_expired");
|
|
314
|
+
|
|
315
|
+
state = await update((current) => {
|
|
316
|
+
const existing = current.intents[profileId];
|
|
317
|
+
if (existing) {
|
|
318
|
+
if (existing.clientType !== input.clientType || (existing.binding && existing.requestedName !== requestedName)) {
|
|
319
|
+
throw fail("device_enrollment_intent_conflict");
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
if (Object.keys(current.intents).length >= 32) throw fail("device_enrollment_instance_limit");
|
|
324
|
+
current.intents[profileId] = { clientType: input.clientType, requestedName,
|
|
325
|
+
registrationIntentId: `reg_${random(24).toString("hex")}`, deviceId: `device_${random(24).toString("hex")}`, binding: null };
|
|
326
|
+
return current;
|
|
327
|
+
});
|
|
328
|
+
let intent = state.intents[profileId];
|
|
329
|
+
// Always recheck the server's active authorization before reattaching a
|
|
330
|
+
// cached profile. An old local success cannot undo a website revocation.
|
|
331
|
+
let registration;
|
|
332
|
+
try { registration = await recoverRegistration(state, intent, profileId); }
|
|
333
|
+
catch (error) {
|
|
334
|
+
// A changed name may replace an explicitly rejected, unfinished attempt.
|
|
335
|
+
// Unknown outcomes never rotate identity or discard recovery candidates.
|
|
336
|
+
if (!nameRejected(error) || intent.binding || intent.requestedName === requestedName) throw error;
|
|
337
|
+
const rejectedName = intent.requestedName;
|
|
338
|
+
state = await update((current) => {
|
|
339
|
+
const saved = current.intents[profileId];
|
|
340
|
+
if (saved.registrationIntentId !== intent.registrationIntentId || saved.deviceId !== intent.deviceId) {
|
|
341
|
+
throw fail("device_enrollment_intent_conflict");
|
|
342
|
+
}
|
|
343
|
+
if (saved.binding) return undefined;
|
|
344
|
+
if (saved.requestedName !== rejectedName) throw fail("device_enrollment_busy");
|
|
345
|
+
saved.previousRequestedNames = [...new Set([...(saved.previousRequestedNames || []), rejectedName])];
|
|
346
|
+
saved.requestedName = requestedName;
|
|
347
|
+
return current;
|
|
348
|
+
});
|
|
349
|
+
intent = state.intents[profileId];
|
|
350
|
+
registration = await recoverRegistration(state, intent, profileId);
|
|
351
|
+
}
|
|
352
|
+
const { registered } = registration;
|
|
353
|
+
intent = registration.intent;
|
|
354
|
+
state = await update((current) => {
|
|
355
|
+
const saved = current.intents[profileId];
|
|
356
|
+
if (saved.registrationIntentId !== intent.registrationIntentId) throw fail("device_enrollment_intent_conflict");
|
|
357
|
+
if (saved.binding && JSON.stringify(saved.binding) !== JSON.stringify(registered)) throw fail("device_enrollment_response_invalid");
|
|
358
|
+
saved.requestedName = intent.requestedName;
|
|
359
|
+
saved.binding = registered;
|
|
360
|
+
delete saved.previousRequestedNames;
|
|
361
|
+
return current;
|
|
362
|
+
});
|
|
363
|
+
intent = state.intents[profileId];
|
|
364
|
+
const b = registered.binding; const i = registered.instance;
|
|
365
|
+
const profile = { contract: "hellolynk-client-profile-v1", schemaVersion: 2, profileId, baseUrl,
|
|
366
|
+
allowLoopbackHttp: options.allowLoopbackHttp === true, deviceId: b.deviceId, credentialId: b.credentialId,
|
|
367
|
+
principalId: b.principalId, hellolynkId: b.hellolynkId, agentId: b.agentId, delegateId: b.delegateId,
|
|
368
|
+
// The API receipt uses "intl", while earlier Hosts store "international".
|
|
369
|
+
// Keep all local profiles consistent without changing any existing binding.
|
|
370
|
+
hostId: b.hostId, authorizationVersion: i.authorizationVersion, region: installation.region || b.region,
|
|
371
|
+
allowedScopes: b.allowedScopes, platform: process.platform };
|
|
372
|
+
try {
|
|
373
|
+
if (!dependencies.readClientProfile) await lstat(configPath);
|
|
374
|
+
const previous = await profileReader(configPath);
|
|
375
|
+
if (previous.delegateId !== b.delegateId || previous.principalId !== b.principalId
|
|
376
|
+
|| previous.credentialId !== b.credentialId || previous.baseUrl !== baseUrl) throw fail("device_enrollment_profile_conflict");
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (!["ENOENT", "client_profile_not_found"].includes(error?.code || error?.message)) throw error;
|
|
379
|
+
}
|
|
380
|
+
const transport = { ...state.approval.hostTransportCredential,
|
|
381
|
+
principalId: b.principalId, hostInstallationId: b.hostId, state: "active", revokedAt: null };
|
|
382
|
+
// An existing Host upgrades atomically before local activation changes.
|
|
383
|
+
// A first Host needs adoption to establish its owner before the same save.
|
|
384
|
+
if (installation.state === "active") await protectedStore.saveEnrollmentTransportCredential(transport);
|
|
385
|
+
// Keep resumable state and the credential on a partial activation failure.
|
|
386
|
+
// Removing them would turn a harmless retry into a duplicate identity.
|
|
387
|
+
await credentialStore.save(profileId, b.deviceCredential);
|
|
388
|
+
await (dependencies.writeClientProfile || writeClientProfile)(configPath, profile);
|
|
389
|
+
const active = await installationStore.adoptProfileFile({ profileFile: configPath, adapterId: input.clientType });
|
|
390
|
+
if (installation.state !== "active") await protectedStore.saveEnrollmentTransportCredential(transport);
|
|
391
|
+
await activateManifest(active, configPath);
|
|
392
|
+
return { ok: true, status: "registered", hellolynkId: b.hellolynkId, clientType: i.clientType,
|
|
393
|
+
instanceName: i.displayName, profileId, configPath, hostProfileRegistered: true,
|
|
394
|
+
...(intent.requestedName !== requestedName ? { recoveredExistingName: true } : {}) };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function activateManifest(installation, configPath) {
|
|
398
|
+
if (dependencies.activateManifest) return dependencies.activateManifest(installation, configPath);
|
|
399
|
+
await writeNeutralHostProfileManifest({ root: hostRoot, clientRoot: root,
|
|
400
|
+
profiles: [configPath], mergeRegisteredProfiles: true });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Only a durable, approved registration can explain a profile that was
|
|
404
|
+
// adopted before its manifest was published. This is read-only evidence for
|
|
405
|
+
// preflight, not authorization to activate: connect still rechecks the server.
|
|
406
|
+
async function verifyLocalRecovery({ clientType, profileId } = {}) {
|
|
407
|
+
if (!CLIENT_TYPES.has(clientType)) throw fail("device_enrollment_client_unsupported");
|
|
408
|
+
assertCredentialProfileId(profileId);
|
|
409
|
+
const state = (await load()).value;
|
|
410
|
+
const active = (await installationStore.status()).installations.filter((item) => item.state === "active");
|
|
411
|
+
const installation = active[0];
|
|
412
|
+
const intent = state?.intents[profileId];
|
|
413
|
+
const saved = intent?.binding;
|
|
414
|
+
if (active.length !== 1 || !state?.approval || !saved || intent.clientType !== clientType
|
|
415
|
+
|| state.hostId !== installation.hostInstallationId || state.approval.principalId !== installation.principalId
|
|
416
|
+
|| !runtimeSafety.helloLynkStableIdMatchesRegion(runtimeSafety.classifyHelloLynkStableId(state.hellolynkId), installation.region)) {
|
|
417
|
+
throw fail("device_enrollment_recovery_invalid");
|
|
418
|
+
}
|
|
419
|
+
if (Date.parse(state.approval.hostTransportCredential.expiresAt) <= now()) throw fail("device_enrollment_authorization_expired");
|
|
420
|
+
const delegate = installation.delegateBindings.find((item) => item.profileId === profileId);
|
|
421
|
+
if (!delegate || delegate.adapters?.length !== 1 || delegate.adapters[0] !== clientType
|
|
422
|
+
|| delegate.delegateId !== saved.binding.delegateId || delegate.authorizationVersion !== saved.instance.authorizationVersion) {
|
|
423
|
+
throw fail("device_enrollment_recovery_invalid");
|
|
424
|
+
}
|
|
425
|
+
const configPath = join(root, "profiles", `${profileId}.json`);
|
|
426
|
+
const profile = await (dependencies.readClientProfile || readClientProfile)(configPath);
|
|
427
|
+
if (profile.profileId !== profileId || profile.baseUrl !== baseUrl
|
|
428
|
+
|| profile.region !== installation.region || profile.authorizationVersion !== delegate.authorizationVersion
|
|
429
|
+
|| ["principalId", "hellolynkId", "delegateId", "agentId", "deviceId", "credentialId", "hostId"]
|
|
430
|
+
.some((field) => profile[field] !== saved.binding[field])) throw fail("device_enrollment_profile_conflict");
|
|
431
|
+
return Object.freeze({ profileId, configPath });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return Object.freeze({ connect, verifyLocalRecovery });
|
|
435
|
+
}
|