@halofy/agent-connect 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/package.json +1 -1
- package/src/enrollment.mjs +83 -0
- package/src/installer-cli.mjs +14 -4
- package/src/version.mjs +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,28 @@ conversations and serves the agent-invoked MCP memory tools, but does not push
|
|
|
22
22
|
recalled memory into host sessions on session start or prompt submit. The
|
|
23
23
|
bounded recall block formats stay in place and tested for when it returns.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
## Browser sign-in (0.12.0 source)
|
|
26
|
+
|
|
27
|
+
Users assigned to a team can run the installer without a manually copied badge:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx @halofy/agent-connect@0.12.0 install claude-code --server https://app.halofy.ai
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`login <client-kind> --server <origin>` is an alias for the same installation
|
|
34
|
+
flow. The installer opens the existing Halofy sign-in page, displays a public
|
|
35
|
+
verification code, and waits for the user to confirm their authorized team and
|
|
36
|
+
device. Google sign-in is available through the existing console authentication.
|
|
37
|
+
A per-installation Ed25519 key is saved privately before enrollment; the approval
|
|
38
|
+
claim is bound to that same key. Device secrets and claims are never printed or
|
|
39
|
+
put in browser URLs. Retry a failed/expired sign-in with the same command; the
|
|
40
|
+
pending key survives retries. Installation still requires the terminal's
|
|
41
|
+
explicit CONNECT disclosure before changing host configuration.
|
|
42
|
+
|
|
43
|
+
This source version requires the matching server enrollment endpoints and a
|
|
44
|
+
published 0.12.0 package before the npx example can be used remotely. The existing
|
|
45
|
+
manual claim path remains supported:
|
|
46
|
+
|
|
26
47
|
|
|
27
48
|
```bash
|
|
28
49
|
npx --yes @halofy/agent-connect@0.10.0 install <client-kind> \
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { generateInstallationKeyPair } from "./crypto.mjs";
|
|
5
|
+
import { normalizeLifecycleServerUrl } from "./install.mjs";
|
|
6
|
+
import { readJson, writePrivateFile, defaultRuntimeDirectory } from "./storage.mjs";
|
|
7
|
+
import { CLIENT_KINDS } from "./client-registry.mjs";
|
|
8
|
+
|
|
9
|
+
export async function prepareInstallationKey(root, clientKind) {
|
|
10
|
+
const path = join(root, `pending-${clientKind}.json`);
|
|
11
|
+
const previous = await readJson(path);
|
|
12
|
+
if (previous) {
|
|
13
|
+
if (!previous.publicJwk || !previous.privateJwk || !previous.installationIdCandidate) {
|
|
14
|
+
throw new Error("pending installation key is invalid; repair local runtime storage before retrying");
|
|
15
|
+
}
|
|
16
|
+
return previous;
|
|
17
|
+
}
|
|
18
|
+
const key = { ...generateInstallationKeyPair(), installationIdCandidate: `local_${randomUUID()}` };
|
|
19
|
+
await writePrivateFile(path, `${JSON.stringify(key)}\n`);
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openVerificationBrowser(uri) {
|
|
24
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
25
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", uri] : [uri];
|
|
26
|
+
const child = spawn(command, args, { shell: false, detached: true, stdio: "ignore" });
|
|
27
|
+
child.on("error", () => {}); // Printed URL remains usable when there is no desktop browser.
|
|
28
|
+
child.unref();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Device secrets stay in memory. Only the public human code reaches browser/terminal. */
|
|
32
|
+
export async function enrollWithBrowser({ serverUrl, clientKind, root = defaultRuntimeDirectory(),
|
|
33
|
+
fetchImpl = globalThis.fetch, output = process.stdout, openBrowser = openVerificationBrowser,
|
|
34
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now }) {
|
|
35
|
+
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("unsupported enrollment client");
|
|
36
|
+
const server = normalizeLifecycleServerUrl(serverUrl);
|
|
37
|
+
const base = new URL(server);
|
|
38
|
+
if (!["https:", "http:"].includes(base.protocol) || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
|
|
39
|
+
throw new Error("sign-in requires a server origin without credentials, path or query");
|
|
40
|
+
}
|
|
41
|
+
const key = await prepareInstallationKey(root, clientKind);
|
|
42
|
+
async function request(path, body) {
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(`${server}/v1/agent-enrollment/${path}`, {
|
|
46
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
|
47
|
+
redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
48
|
+
});
|
|
49
|
+
} catch { throw new Error("sign-in connection unavailable; retry sign-in"); }
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(response.status === 429 ? "sign-in rate limit reached; try again later" :
|
|
52
|
+
response.status === 410 ? "sign-in expired; retry sign-in" : "sign-in denied or unavailable; retry sign-in");
|
|
53
|
+
}
|
|
54
|
+
try { return await response.json(); } catch { throw new Error("invalid sign-in response"); }
|
|
55
|
+
}
|
|
56
|
+
const start = await request("start", { clientKind, publicKeyJwk: key.publicJwk });
|
|
57
|
+
let uri;
|
|
58
|
+
try { uri = new URL(start.verificationUri); } catch { throw new Error("invalid sign-in response"); }
|
|
59
|
+
if (typeof start.deviceCode !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(start.deviceCode) ||
|
|
60
|
+
typeof start.userCode !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(start.userCode) ||
|
|
61
|
+
uri.origin !== base.origin || uri.username || uri.password || uri.pathname !== "/console/connect" ||
|
|
62
|
+
uri.hash || uri.search !== `?code=${start.userCode}` ||
|
|
63
|
+
!Number.isFinite(start.expiresIn) || start.expiresIn <= 0 || start.expiresIn > 1800 ||
|
|
64
|
+
!Number.isFinite(start.interval) || start.interval < 1 || start.interval > 60) {
|
|
65
|
+
throw new Error("invalid sign-in response");
|
|
66
|
+
}
|
|
67
|
+
const deadline = now() + start.expiresIn * 1000;
|
|
68
|
+
output.write(`Sign in and confirm this device: ${uri.href}\nCode: ${start.userCode}\n`);
|
|
69
|
+
try { await openBrowser(uri.href); } catch { /* URL is available for manual opening. */ }
|
|
70
|
+
while (now() < deadline) {
|
|
71
|
+
await sleep(start.interval * 1000);
|
|
72
|
+
if (now() >= deadline) break;
|
|
73
|
+
const token = await request("token", { deviceCode: start.deviceCode });
|
|
74
|
+
if (token.status === "pending") continue;
|
|
75
|
+
if (token.status === "expired") throw new Error("sign-in expired; retry sign-in");
|
|
76
|
+
if (token.status === "denied") throw new Error("sign-in denied; retry sign-in");
|
|
77
|
+
if (token.status !== "approved" || token.clientKind !== clientKind ||
|
|
78
|
+
typeof token.claim !== "string" || !/^hsc_[A-Za-z0-9_-]{43}$/.test(token.claim) ||
|
|
79
|
+
typeof token.organization !== "string" || !token.organization.trim()) throw new Error("invalid sign-in response");
|
|
80
|
+
return { claim: token.claim, organization: token.organization.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().slice(0, 100) };
|
|
81
|
+
}
|
|
82
|
+
throw new Error("sign-in expired; retry sign-in");
|
|
83
|
+
}
|
package/src/installer-cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enrollWithBrowser } from "./enrollment.mjs";
|
|
1
2
|
import { recordHealthTargets } from "./health.mjs";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { existsSync, readdirSync } from "node:fs";
|
|
@@ -78,9 +79,9 @@ export function parseInstallerArgs(argv) {
|
|
|
78
79
|
}
|
|
79
80
|
const serverUrl = values.get("--server");
|
|
80
81
|
const claim = values.get("--claim");
|
|
81
|
-
if (
|
|
82
|
-
!/^hsc_[A-Za-z0-9_-]{43}$/.test(claim)) {
|
|
83
|
-
throw new Error("Usage: agent-connect install <supported-client> --server <https-url> --claim <one-use-claim>");
|
|
82
|
+
if (!["install", "login"].includes(command) || !CLIENT_KINDS.includes(clientKind) || !serverUrl ||
|
|
83
|
+
(claim !== undefined && !/^hsc_[A-Za-z0-9_-]{43}$/.test(claim))) {
|
|
84
|
+
throw new Error("Usage: agent-connect install|login <supported-client> --server <https-url> [--claim <one-use-claim>]");
|
|
84
85
|
}
|
|
85
86
|
return {
|
|
86
87
|
mode: "single",
|
|
@@ -426,6 +427,9 @@ export async function runInstaller(argv, {
|
|
|
426
427
|
sourceRoot,
|
|
427
428
|
claudeConfigPath,
|
|
428
429
|
skillsHome,
|
|
430
|
+
openBrowser,
|
|
431
|
+
enrollmentSleep,
|
|
432
|
+
enrollmentNow,
|
|
429
433
|
} = {}) {
|
|
430
434
|
const input = parseInstallerArgs(argv);
|
|
431
435
|
if (input.mode === "all") {
|
|
@@ -435,7 +439,13 @@ export async function runInstaller(argv, {
|
|
|
435
439
|
});
|
|
436
440
|
}
|
|
437
441
|
const clientVersion = input.clientKind === "claude-code" ? await detectClaude() : await detectHost(input.clientKind);
|
|
438
|
-
const
|
|
442
|
+
const enrollment = !input.claim ? await enrollWithBrowser({
|
|
443
|
+
serverUrl: input.serverUrl, clientKind: input.clientKind, root, fetchImpl, output,
|
|
444
|
+
...(openBrowser ? { openBrowser } : {}), ...(enrollmentSleep ? { sleep: enrollmentSleep } : {}),
|
|
445
|
+
...(enrollmentNow ? { now: enrollmentNow } : {}),
|
|
446
|
+
}) : null;
|
|
447
|
+
if (enrollment) input.claim = enrollment.claim;
|
|
448
|
+
const claimDisclosure = enrollment || await fetchClaimDisclosure({
|
|
439
449
|
serverUrl: input.serverUrl,
|
|
440
450
|
claim: input.claim,
|
|
441
451
|
fetchImpl,
|
package/src/version.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const PACKAGE_NAME = "@halofy/agent-connect";
|
|
2
|
-
export const INSTALLER_VERSION = "0.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
2
|
+
export const INSTALLER_VERSION = "0.12.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.12.0";
|
|
4
4
|
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";
|