@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 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
- There is one setup path for every packaged client:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
6
6
  "bin": {
@@ -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
+ }
@@ -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 (command !== "install" || !CLIENT_KINDS.includes(clientKind) || !serverUrl || !claim ||
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 claimDisclosure = await fetchClaimDisclosure({
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.11.0";
3
- export const RUNTIME_VERSION = "0.11.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";