@halofy/agent-connect 0.11.0 → 0.12.1

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
@@ -1,7 +1,9 @@
1
1
  # Halofy agent lifecycle installer
2
2
 
3
- Status: published installer (`@halofy/agent-connect@0.10.0`). The deployment
4
- workflow pins the verified tarball; host loading requires separate verification.
3
+ Status: source for `@halofy/agent-connect@0.12.1`; publication and deployment
4
+ require separate verification. The production console supplies the command for
5
+ the verified published package pinned by the deployment workflow. Host loading
6
+ requires separate verification.
5
7
 
6
8
  This package contains the host-neutral client pieces for installation-bound
7
9
  agent connections:
@@ -22,10 +24,32 @@ conversations and serves the agent-invoked MCP memory tools, but does not push
22
24
  recalled memory into host sessions on session start or prompt submit. The
23
25
  bounded recall block formats stay in place and tested for when it returns.
24
26
 
25
- There is one setup path for every packaged client:
27
+ ## Browser sign-in
28
+
29
+ Users assigned to a team can run the installer without a manually copied badge.
30
+ Use the production console’s verified command. After 0.12.1 is published and
31
+ verified, the command for this source version is:
26
32
 
27
33
  ```bash
28
- npx --yes @halofy/agent-connect@0.10.0 install <client-kind> \
34
+ npx @halofy/agent-connect@0.12.1 install claude-code --server https://app.halofy.ai
35
+ ```
36
+
37
+ `login <client-kind> --server <origin>` is an alias for the same installation
38
+ flow. The installer opens the existing Halofy sign-in page, displays a public
39
+ verification code, and waits for the user to confirm their authorized team and
40
+ device. Google sign-in is available through the existing console authentication.
41
+ A per-installation Ed25519 key is saved privately before enrollment; the approval
42
+ claim is bound to that same key. Device secrets and claims are never printed or
43
+ put in browser URLs. Retry a failed/expired sign-in with the same command; the
44
+ pending key survives retries. Installation still requires the terminal's
45
+ explicit CONNECT disclosure before changing host configuration.
46
+
47
+ Browser sign-in requires the matching server enrollment endpoints. The example
48
+ above does not establish that 0.12.1 is published or enabled in production. The
49
+ manual claim path remains supported; after publication, its 0.12.1 form is:
50
+
51
+ ```bash
52
+ npx --yes @halofy/agent-connect@0.12.1 install <client-kind> \
29
53
  --server https://app.halofy.ai \
30
54
  --claim '<one-time-claim>'
31
55
  ```
@@ -199,6 +223,16 @@ memory recall. There is no resident instruction daemon; an offline device or a
199
223
  host session without an installed startup hook does not fetch updates. Claude's
200
224
  existing hook installation is project-scoped even though its rule file is global.
201
225
 
226
+ Version 0.12.1 gives instruction GET and its best-effort status receipt one shared
227
+ 8-second deadline at startup or direct refresh, and 15 seconds during interactive
228
+ installation. A failed GET preserves current instructions; a receipt timeout does
229
+ not undo a completed local update or prove server acknowledgement. Other request
230
+ and host-hook limits are unchanged; this is not a deadline guarantee for the
231
+ whole startup sequence. Startup runs instruction sync alongside skill/reference
232
+ delivery, including while capture is paused, so slow reference downloads or
233
+ receipts do not hold up the instruction fetch. Replay and heartbeat still follow
234
+ both refreshes.
235
+
202
236
  | Host | Local target | Boundaries |
203
237
  |---|---|---|
204
238
  | Codex | `CODEX_HOME` or `~/.codex`, active nonempty `AGENTS.override.md` else `AGENTS.md` | Marked section; changing the active target reports a conflict |
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.1",
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": {
@@ -12,6 +12,15 @@ export async function syncSkillsAtSessionStart(runtime, connection, root, stderr
12
12
  return result.skills;
13
13
  }
14
14
 
15
+ /** Disjoint owned files and locks let instructions refresh without waiting for
16
+ * skill/reference downloads or their receipt. Both settle before capture work. */
17
+ export async function syncConfigurationAtSessionStart(runtime, connection, root, stderr, syncOptions = {}) {
18
+ await Promise.allSettled([
19
+ Promise.resolve().then(() => runtime.syncInstructions?.()),
20
+ syncSkillsAtSessionStart(runtime, connection, root, stderr, syncOptions),
21
+ ]);
22
+ }
23
+
15
24
  function id(value) {
16
25
  return createHash("sha256").update(String(value)).digest("hex");
17
26
  }
@@ -96,9 +105,8 @@ export async function runClaudeLifecycleHook(connection, eventName, {
96
105
  const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
97
106
  if (capture.paused) {
98
107
  if (eventName === "SessionStart") {
99
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
100
- // Capture pause does not suspend governed instruction delivery.
101
- await runtime.syncInstructions?.().catch(() => {});
108
+ // Capture pause does not suspend instruction or reference delivery.
109
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
102
110
  await runtime.heartbeat(connection.capabilities || {});
103
111
  } else if (eventName === "UserPromptSubmit") {
104
112
  const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
@@ -110,9 +118,7 @@ export async function runClaudeLifecycleHook(connection, eventName, {
110
118
 
111
119
  if (eventName === "SessionStart") {
112
120
  // Refresh/withdraw local context even when replay or heartbeat fails.
113
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
114
- // Instructions have their own brownout boundary, independent of recall.
115
- await runtime.syncInstructions?.().catch(() => {});
121
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
116
122
  await runtime.replay();
117
123
  await runtime.heartbeat(connection.capabilities || {});
118
124
  if (RECALL_INJECTION_ENABLED) {
@@ -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/host-hook.mjs CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  rankedRecallBlocks,
10
10
  } from "./session.mjs";
11
11
  import { defaultRuntimeDirectory } from "./storage.mjs";
12
- import { readHookInput, syncSkillsAtSessionStart } from "./claude-hook.mjs";
12
+ import { readHookInput, syncConfigurationAtSessionStart } from "./claude-hook.mjs";
13
13
  import { syncManagedDelivery } from "./delivery-sync.mjs";
14
14
  import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
15
15
 
@@ -161,9 +161,8 @@ export async function runHostLifecycleHook(connection, eventName, {
161
161
  const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
162
162
  if (capture.paused) {
163
163
  if (START_EVENTS.has(eventName)) {
164
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
165
- // Capture pause does not suspend governed instruction delivery.
166
- await runtime.syncInstructions?.().catch(() => {});
164
+ // Capture pause does not suspend instruction or reference delivery.
165
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
167
166
  await runtime.heartbeat(connection.capabilities || {});
168
167
  } else if (USER_EVENTS.has(eventName)) {
169
168
  const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
@@ -175,9 +174,7 @@ export async function runHostLifecycleHook(connection, eventName, {
175
174
 
176
175
  if (START_EVENTS.has(eventName)) {
177
176
  // Refresh/withdraw local context even when replay or heartbeat fails.
178
- await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
179
- // Instructions have their own brownout boundary, independent of recall.
180
- await runtime.syncInstructions?.().catch(() => {});
177
+ await syncConfigurationAtSessionStart(runtime, connection, root, stderr, { home, env });
181
178
  await runtime.replay();
182
179
  await runtime.heartbeat(connection.capabilities || {});
183
180
  if (RECALL_INJECTION_ENABLED) {
package/src/install.mjs CHANGED
@@ -4,7 +4,7 @@ import { chmod, cp, rename, rm, unlink } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { generateInstallationKeyPair } from "./crypto.mjs";
6
6
  import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
7
- import { syncAgentInstructions } from "./instructions.mjs";
7
+ import { INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS, syncAgentInstructions } from "./instructions.mjs";
8
8
  import { SignedRuntimeTransport } from "./transport.mjs";
9
9
  import { inspectCaptureHealth } from "./health.mjs";
10
10
  import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
@@ -232,7 +232,7 @@ export async function installLocalConnection({
232
232
  }
233
233
  }
234
234
  const instructions = instructionProfile
235
- ? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
235
+ ? await syncAgentInstructions(connection, { root, fetchImpl, timeoutMs: INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS })
236
236
  : undefined;
237
237
  return {
238
238
  installationId,
@@ -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,
@@ -7,6 +7,9 @@ import { isActiveInstallation } from "./active.mjs";
7
7
  import { ConnectionStore, readJson } from "./storage.mjs";
8
8
  import { SignedRuntimeTransport } from "./transport.mjs";
9
9
 
10
+ export const INSTRUCTION_SYNC_TIMEOUT_MS = 8_000;
11
+ export const INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS = 15_000;
12
+
10
13
  const sha = (value) => createHash("sha256").update(value).digest("hex");
11
14
  const HEX = /^[a-f0-9]{64}$/;
12
15
  const MARKER = "<!-- HALOFY-INSTRUCTIONS:";
@@ -247,20 +250,32 @@ async function retirePredecessorInstructions(connection, root) {
247
250
  }
248
251
  }
249
252
 
250
- /** Separate best-effort operation: a failed GET never means remove instructions. */
251
- export async function syncAgentInstructions(connection, { root, transport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }) } = {}) {
253
+ /** Separate best-effort operation: a failed GET never means remove instructions.
254
+ * GET and receipt share one deadline so a slow receipt cannot double startup delay.
255
+ * An explicitly injected transport retains its own cancellation policy. */
256
+ export async function syncAgentInstructions(connection, {
257
+ root, transport, fetchImpl = globalThis.fetch, timeoutMs = INSTRUCTION_SYNC_TIMEOUT_MS,
258
+ } = {}) {
259
+ const controller = new AbortController();
260
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
261
+ const activeTransport = transport ?? new SignedRuntimeTransport(connection, {
262
+ fetchImpl, timeoutMs, signal: controller.signal,
263
+ });
252
264
  let receipt = { digest: sha("[]"), status: "unavailable", reason: "fetch_unavailable" };
253
265
  try {
254
- if (!(await isActiveInstallation(connection, root))) return { ...receipt, status: "unsupported", reason: "inactive_installation" };
255
- await retirePredecessorInstructions(connection, root);
256
- const bundle = await transport.instructions();
257
- receipt.digest = typeof bundle?.digest === "string" && HEX.test(bundle.digest) ? bundle.digest : receipt.digest;
258
- receipt = await applyInstructionBundle(connection, bundle, { root, canActivate: () => isActiveInstallation(connection, root) });
259
- } catch (error) {
260
- const reason = error.instructionCode;
261
- const unsupported = ["custom_context_unsupported", "reinstall_required", "inactive_installation"].includes(reason);
262
- receipt = { ...receipt, status: unsupported ? "unsupported" : reason && reason !== "invalid_bundle" ? "conflict" : "unavailable", reason: reason || "fetch_unavailable" };
263
- }
264
- try { await transport.instructionStatus(receipt); } catch { /* A receipt failure cannot disable capture/MCP. */ }
265
- return receipt;
266
+ try {
267
+ if (!(await isActiveInstallation(connection, root))) return { ...receipt, status: "unsupported", reason: "inactive_installation" };
268
+ await retirePredecessorInstructions(connection, root);
269
+ const bundle = await activeTransport.instructions();
270
+ if (!transport) controller.signal.throwIfAborted();
271
+ receipt.digest = typeof bundle?.digest === "string" && HEX.test(bundle.digest) ? bundle.digest : receipt.digest;
272
+ receipt = await applyInstructionBundle(connection, bundle, { root, canActivate: () => isActiveInstallation(connection, root) });
273
+ } catch (error) {
274
+ const reason = error.instructionCode;
275
+ const unsupported = ["custom_context_unsupported", "reinstall_required", "inactive_installation"].includes(reason);
276
+ receipt = { ...receipt, status: unsupported ? "unsupported" : reason && reason !== "invalid_bundle" ? "conflict" : "unavailable", reason: reason || "fetch_unavailable" };
277
+ }
278
+ try { await activeTransport.instructionStatus(receipt); } catch { /* A receipt failure cannot disable capture/MCP. */ }
279
+ return receipt;
280
+ } finally { clearTimeout(timer); }
266
281
  }
package/src/runtime.mjs CHANGED
@@ -16,7 +16,7 @@ export class LifecycleRuntime {
16
16
  constructor(connection, {
17
17
  root,
18
18
  transport = new SignedRuntimeTransport(connection),
19
- instructionTransport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }),
19
+ instructionTransport,
20
20
  maxBatchEvents = 100,
21
21
  maxBatchBytes = 1024 * 1024,
22
22
  commitThreshold = 20,
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.1";
3
+ export const RUNTIME_VERSION = "0.12.1";
4
4
  export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";