@halofy/agent-connect 0.9.0 → 0.10.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 +56 -1
- package/package.json +1 -1
- package/src/claude-hook.mjs +2 -0
- package/src/host-hook.mjs +2 -0
- package/src/install.mjs +8 -0
- package/src/installer-cli.mjs +11 -5
- package/src/instructions.mjs +266 -0
- package/src/runtime.mjs +11 -0
- package/src/transport.mjs +2 -0
- package/src/version.mjs +3 -3
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ bounded recall block formats stay in place and tested for when it returns.
|
|
|
25
25
|
There is one setup path for every packaged client:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npx --yes @halofy/agent-connect@0.
|
|
28
|
+
npx --yes @halofy/agent-connect@0.10.0 install <client-kind> \
|
|
29
29
|
--server https://app.halofy.ai \
|
|
30
30
|
--claim '<one-time-claim>'
|
|
31
31
|
```
|
|
@@ -155,3 +155,58 @@ confirm delivery status. Existing offline skill limits remain; context refresh
|
|
|
155
155
|
failures withdraw the managed references. These are source capabilities; package
|
|
156
156
|
publication, installed-client upgrades and production deployment require separate
|
|
157
157
|
release verification.
|
|
158
|
+
## Organization instructions (0.10.0 release candidate)
|
|
159
|
+
|
|
160
|
+
This source adds Govern instruction delivery alongside the existing skill,
|
|
161
|
+
policy, knowledge and delivery-receipt paths. Activation requires the reviewed
|
|
162
|
+
0.10.0 npm publication, matching server deployment and a fresh confirmed
|
|
163
|
+
installation. Source versioning alone is not publication evidence. Existing
|
|
164
|
+
connections without the new local instruction consent/profile snapshot do not
|
|
165
|
+
gain global file-writing authority.
|
|
166
|
+
|
|
167
|
+
The installer discloses global rule management alongside existing capture and
|
|
168
|
+
MCP behavior. It freezes the selected profile and server-provided namespace in
|
|
169
|
+
the local connection and fetches instructions with the installation proof.
|
|
170
|
+
Subsequent supported session-start hooks refresh them independently of disabled
|
|
171
|
+
memory recall. There is no resident instruction daemon; an offline device or a
|
|
172
|
+
host session without an installed startup hook does not fetch updates. Claude's
|
|
173
|
+
existing hook installation is project-scoped even though its rule file is global.
|
|
174
|
+
|
|
175
|
+
| Host | Local target | Boundaries |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| Codex | `CODEX_HOME` or `~/.codex`, active nonempty `AGENTS.override.md` else `AGENTS.md` | Marked section; changing the active target reports a conflict |
|
|
178
|
+
| Claude Code | `CLAUDE_CONFIG_DIR` or `~/.claude`, dedicated `rules/halofy-*.md` | Existing personal/project `CLAUDE.md` remains untouched |
|
|
179
|
+
| Gemini CLI | `GEMINI_CLI_HOME` or home, then `.gemini/GEMINI.md` | Marked section; custom discovery excluding `GEMINI.md` is unsupported |
|
|
180
|
+
| Cursor | `~/.cursor/rules/halofy-*.mdc` with `alwaysApply: true` | Agent Chat only; no claim for Tab, Inline Edit, or cloud hosts |
|
|
181
|
+
| Other packaged hosts | No instruction writes | Reports unsupported; existing capture/MCP continues |
|
|
182
|
+
|
|
183
|
+
Host mechanisms were checked against official documentation on 2026-09-10:
|
|
184
|
+
[Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md),
|
|
185
|
+
[Claude Code](https://code.claude.com/docs/en/memory),
|
|
186
|
+
[Gemini CLI](https://geminicli.com/docs/cli/gemini-md/), and
|
|
187
|
+
[Cursor](https://prod.cursor.com/help/customization/rules).
|
|
188
|
+
These are file-format adapters, not real-host loading certification. Host
|
|
189
|
+
precedence, context limits, project configuration and exclusion settings still
|
|
190
|
+
apply. Receipts say `pending_restart`, never loaded or obeyed. Other local OS
|
|
191
|
+
accounts, containers and remote/cloud profiles require their own installation.
|
|
192
|
+
|
|
193
|
+
Sync validates exact content and bundle digests, scope ancestry/order and size
|
|
194
|
+
before writing. Personal bytes outside a managed block are preserved exactly.
|
|
195
|
+
An isolated ownership manifest, exclusive profile lock, no-follow reads,
|
|
196
|
+
component symlink checks, private backups, concurrent-edit checks and atomic
|
|
197
|
+
replacement protect local content. Unexpected edits, broken/duplicate markers,
|
|
198
|
+
linked files or changed target selection fail without replacing host content.
|
|
199
|
+
Request failure leaves the current installation’s instructions unchanged. A
|
|
200
|
+
confirmed reconnect first retires only verified predecessor-owned instruction
|
|
201
|
+
bytes; old hooks cannot restore them. Conflicting predecessor edits prevent new
|
|
202
|
+
instruction activation while preserving all user content. Otherwise, only a
|
|
203
|
+
verified empty bundle removes managed content; an empty file and removal manifest remain so a
|
|
204
|
+
future authorized re-enable can be recognized safely. Backup files stay outside
|
|
205
|
+
host rules directories, under the runtime instructions directory.
|
|
206
|
+
|
|
207
|
+
A stale lock is deliberately not automatically deleted; after confirming no
|
|
208
|
+
sync is running, an operator may remove `.halofy-instructions.lock` in the
|
|
209
|
+
selected profile. Local filesystem errors and receipt failures do not interrupt
|
|
210
|
+
capture or MCP. Neither instruction text, filesystem paths nor backups are sent
|
|
211
|
+
in status receipts. Existing policy/knowledge/skill operations and runtime queue
|
|
212
|
+
files are not changed by instruction sync.
|
package/package.json
CHANGED
package/src/claude-hook.mjs
CHANGED
|
@@ -96,6 +96,8 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
96
96
|
if (eventName === "SessionStart") {
|
|
97
97
|
// Refresh/withdraw local context even when replay or heartbeat fails.
|
|
98
98
|
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
99
|
+
// Instructions have their own brownout boundary, independent of recall.
|
|
100
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
99
101
|
await runtime.replay();
|
|
100
102
|
await runtime.heartbeat(connection.capabilities || {});
|
|
101
103
|
if (RECALL_INJECTION_ENABLED) {
|
package/src/host-hook.mjs
CHANGED
|
@@ -161,6 +161,8 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
161
161
|
if (START_EVENTS.has(eventName)) {
|
|
162
162
|
// Refresh/withdraw local context even when replay or heartbeat fails.
|
|
163
163
|
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
164
|
+
// Instructions have their own brownout boundary, independent of recall.
|
|
165
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
164
166
|
await runtime.replay();
|
|
165
167
|
await runtime.heartbeat(connection.capabilities || {});
|
|
166
168
|
if (RECALL_INJECTION_ENABLED) {
|
package/src/install.mjs
CHANGED
|
@@ -4,6 +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
8
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
8
9
|
import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
|
|
9
10
|
import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
|
|
@@ -151,6 +152,7 @@ export async function installLocalConnection({
|
|
|
151
152
|
root = defaultRuntimeDirectory(),
|
|
152
153
|
fetchImpl = globalThis.fetch,
|
|
153
154
|
sendHeartbeat = true,
|
|
155
|
+
instructionProfile,
|
|
154
156
|
}) {
|
|
155
157
|
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
|
|
156
158
|
const client = lifecycleClient(clientKind);
|
|
@@ -201,6 +203,8 @@ export async function installLocalConnection({
|
|
|
201
203
|
pluginVersion: RUNTIME_VERSION,
|
|
202
204
|
proofStorage,
|
|
203
205
|
installedAt: new Date().toISOString(),
|
|
206
|
+
...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
|
|
207
|
+
...(instructionProfile ? { instructionProfile } : {}),
|
|
204
208
|
};
|
|
205
209
|
await store.save(connection);
|
|
206
210
|
await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
|
|
@@ -225,9 +229,13 @@ export async function installLocalConnection({
|
|
|
225
229
|
heartbeat = false;
|
|
226
230
|
}
|
|
227
231
|
}
|
|
232
|
+
const instructions = instructionProfile
|
|
233
|
+
? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
|
|
234
|
+
: undefined;
|
|
228
235
|
return {
|
|
229
236
|
installationId,
|
|
230
237
|
previousInstallationId: connection.previousInstallationId ?? null,
|
|
238
|
+
...(instructions ? { instructions } : {}),
|
|
231
239
|
heartbeat,
|
|
232
240
|
reused: false,
|
|
233
241
|
proofStorage: connection.proofStorage,
|
package/src/installer-cli.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { configureClaudeProject } from "./claude-config.mjs";
|
|
|
16
16
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
17
17
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
18
18
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
19
|
+
import { instructionProfile } from "./instructions.mjs";
|
|
19
20
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
20
21
|
import { managedSkillsDirectory } from "./skills-sync.mjs";
|
|
21
22
|
|
|
@@ -195,9 +196,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
195
196
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
196
197
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
197
198
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
"This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
200
|
+
"Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
|
|
201
|
+
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
201
202
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
202
203
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
203
204
|
].join("\n");
|
|
@@ -250,8 +251,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
250
251
|
lines.push(
|
|
251
252
|
"",
|
|
252
253
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
253
|
-
"This reads and writes
|
|
254
|
-
"
|
|
254
|
+
"This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
255
|
+
"Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
|
|
256
|
+
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
255
257
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
256
258
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
257
259
|
);
|
|
@@ -354,6 +356,7 @@ async function runAllInstaller(input, {
|
|
|
354
356
|
root,
|
|
355
357
|
fetchImpl,
|
|
356
358
|
sendHeartbeat: false,
|
|
359
|
+
instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
357
360
|
});
|
|
358
361
|
const configured = await configureHost(host.clientKind, {
|
|
359
362
|
projectRoot: input.projectRoot,
|
|
@@ -383,6 +386,7 @@ async function runAllInstaller(input, {
|
|
|
383
386
|
conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
|
|
384
387
|
: null,
|
|
385
388
|
proofStorage: installed.proofStorage,
|
|
389
|
+
instructions: installed.instructions,
|
|
386
390
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
387
391
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
388
392
|
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
@@ -448,6 +452,7 @@ export async function runInstaller(argv, {
|
|
|
448
452
|
root,
|
|
449
453
|
fetchImpl,
|
|
450
454
|
sendHeartbeat: false,
|
|
455
|
+
instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
451
456
|
});
|
|
452
457
|
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
453
458
|
const common = {
|
|
@@ -477,6 +482,7 @@ export async function runInstaller(argv, {
|
|
|
477
482
|
installerVersion: INSTALLER_VERSION,
|
|
478
483
|
publishedPackage: true,
|
|
479
484
|
projectConfigured: true,
|
|
485
|
+
instructions: installed.instructions,
|
|
480
486
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
481
487
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
482
488
|
mcpConfiguration: localMcpSnippet({
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { isActiveInstallation } from "./active.mjs";
|
|
7
|
+
import { ConnectionStore, readJson } from "./storage.mjs";
|
|
8
|
+
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
9
|
+
|
|
10
|
+
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
12
|
+
const MARKER = "<!-- HALOFY-INSTRUCTIONS:";
|
|
13
|
+
const fail = (code) => { throw Object.assign(new Error(code), { instructionCode: code }); };
|
|
14
|
+
const plain = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
const scope = (value) => typeof value === "string" && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value) &&
|
|
16
|
+
(value === "" || value.split("/").every((part) => part && part !== "." && part !== ".."));
|
|
17
|
+
|
|
18
|
+
/** Local consent/profile snapshot; never derive a writable path from a server response. */
|
|
19
|
+
export function instructionProfile(clientKind, { home = homedir(), env = process.env } = {}) {
|
|
20
|
+
const roots = {
|
|
21
|
+
codex: env.CODEX_HOME || join(home, ".codex"),
|
|
22
|
+
"claude-code": env.CLAUDE_CONFIG_DIR || join(home, ".claude"),
|
|
23
|
+
"gemini-cli": join(env.GEMINI_CLI_HOME || home, ".gemini"),
|
|
24
|
+
cursor: join(home, ".cursor"),
|
|
25
|
+
};
|
|
26
|
+
return { version: 1, consent: "organization-instructions-v1", clientKind, root: roots[clientKind] ? resolve(roots[clientKind]) : null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateInstructionBundle(bundle, expectedNamespace) {
|
|
30
|
+
if (!plain(bundle) || bundle.schemaVersion !== 1 || !scope(expectedNamespace) ||
|
|
31
|
+
bundle.namespace !== expectedNamespace || !HEX.test(bundle.digest) || !Array.isArray(bundle.documents) ||
|
|
32
|
+
Buffer.byteLength(JSON.stringify(bundle)) > 64 * 1024) fail("invalid_bundle");
|
|
33
|
+
let previous = null;
|
|
34
|
+
const documents = bundle.documents.map((doc) => {
|
|
35
|
+
if (!plain(doc) || typeof doc.id !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(doc.id) || !scope(doc.namespace) ||
|
|
36
|
+
!(doc.namespace === "" || doc.namespace === expectedNamespace || expectedNamespace.startsWith(`${doc.namespace}/`)) ||
|
|
37
|
+
(previous !== null && !(doc.namespace !== previous && (previous === "" || doc.namespace.startsWith(`${previous}/`)))) ||
|
|
38
|
+
!Number.isSafeInteger(doc.version) || doc.version < 1 || typeof doc.title !== "string" || !doc.title.trim() || doc.title.length > 160 || doc.title.includes(MARKER) ||
|
|
39
|
+
/[\u0000-\u001f\u007f]/.test(doc.title) || typeof doc.content !== "string" || !doc.content.trim() ||
|
|
40
|
+
Buffer.from(doc.title, "utf8").toString("utf8") !== doc.title || Buffer.from(doc.content, "utf8").toString("utf8") !== doc.content ||
|
|
41
|
+
doc.content.includes("\0") || doc.content.includes(MARKER) || Buffer.byteLength(doc.content) > 16 * 1024 ||
|
|
42
|
+
!HEX.test(doc.sha256) || sha(doc.content) !== doc.sha256) fail("invalid_bundle");
|
|
43
|
+
previous = doc.namespace;
|
|
44
|
+
return { id: doc.id, namespace: doc.namespace, version: doc.version, title: doc.title, content: doc.content, sha256: doc.sha256 };
|
|
45
|
+
});
|
|
46
|
+
if (sha(JSON.stringify(documents)) !== bundle.digest) fail("invalid_bundle");
|
|
47
|
+
return { ...bundle, documents };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Inspect every existing path component, including the final file. mkdir is
|
|
51
|
+
// deliberately incremental: recursive mkdir can follow a linked ancestor.
|
|
52
|
+
async function safePath(path, { createParents = false } = {}) {
|
|
53
|
+
if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || path.includes("\0")) fail("unsafe_path");
|
|
54
|
+
const parsed = parse(path);
|
|
55
|
+
const parts = path.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
56
|
+
let current = parsed.root;
|
|
57
|
+
const chain = [];
|
|
58
|
+
for (let i = 0; i < parts.length; i++) {
|
|
59
|
+
current = join(current, parts[i]);
|
|
60
|
+
let info;
|
|
61
|
+
try { info = await lstat(current); } catch (error) {
|
|
62
|
+
if (error.code !== "ENOENT") throw error;
|
|
63
|
+
if (createParents && i < parts.length - 1) {
|
|
64
|
+
try { await mkdir(current, { mode: 0o700 }); } catch (made) { if (made.code !== "EEXIST") throw made; }
|
|
65
|
+
info = await lstat(current);
|
|
66
|
+
} else return chain;
|
|
67
|
+
}
|
|
68
|
+
if (info.isSymbolicLink() || (i < parts.length - 1 ? !info.isDirectory() : !info.isFile() && !info.isDirectory())) fail("unsafe_path");
|
|
69
|
+
chain.push(`${current}:${info.dev}:${info.ino}`);
|
|
70
|
+
}
|
|
71
|
+
return chain;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function snapshot(path) {
|
|
75
|
+
await safePath(path);
|
|
76
|
+
let handle;
|
|
77
|
+
try {
|
|
78
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
79
|
+
const info = await handle.stat();
|
|
80
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 1024 * 1024) fail("unsafe_file");
|
|
81
|
+
const bytes = await handle.readFile();
|
|
82
|
+
return { bytes, mode: info.mode & 0o777, identity: `${info.dev}:${info.ino}:${info.mtimeMs}:${info.ctimeMs}`, exists: true };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === "ENOENT") return { bytes: Buffer.alloc(0), mode: 0o600, identity: null, exists: false };
|
|
85
|
+
throw error;
|
|
86
|
+
} finally { await handle?.close(); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function atomic(path, bytes, prior) {
|
|
90
|
+
const ancestors = await safePath(path, { createParents: true });
|
|
91
|
+
const temp = `${path}.${process.pid}.${randomBytes(10).toString("hex")}.tmp`;
|
|
92
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), prior.mode);
|
|
93
|
+
try {
|
|
94
|
+
await handle.writeFile(bytes);
|
|
95
|
+
await handle.sync();
|
|
96
|
+
await handle.close();
|
|
97
|
+
const now = await snapshot(path);
|
|
98
|
+
if (now.identity !== prior.identity || !now.bytes.equals(prior.bytes) ||
|
|
99
|
+
JSON.stringify(await safePath(path)) !== JSON.stringify(ancestors)) fail("concurrent_edit");
|
|
100
|
+
await rename(temp, path);
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close().catch(() => {});
|
|
103
|
+
await unlink(temp).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function targetFor(connection, owner) {
|
|
108
|
+
const profile = connection.instructionProfile;
|
|
109
|
+
if (!plain(profile) || profile.version !== 1 || profile.consent !== "organization-instructions-v1" ||
|
|
110
|
+
profile.clientKind !== connection.clientKind) fail("reinstall_required");
|
|
111
|
+
if (!["codex", "claude-code", "gemini-cli", "cursor"].includes(connection.clientKind)) return null;
|
|
112
|
+
const root = profile.root;
|
|
113
|
+
await safePath(root);
|
|
114
|
+
if (connection.clientKind === "codex") {
|
|
115
|
+
const override = join(root, "AGENTS.override.md");
|
|
116
|
+
const file = await snapshot(override);
|
|
117
|
+
return { path: file.bytes.toString("utf8").trim() ? override : join(root, "AGENTS.md"), dedicated: false };
|
|
118
|
+
}
|
|
119
|
+
if (connection.clientKind === "gemini-cli") {
|
|
120
|
+
const settings = await snapshot(join(root, "settings.json"));
|
|
121
|
+
let parsed;
|
|
122
|
+
try { parsed = settings.exists ? JSON.parse(settings.bytes.toString("utf8")) : {}; } catch { fail("host_settings_invalid"); }
|
|
123
|
+
const filename = parsed.context?.fileName;
|
|
124
|
+
// A non-default discovery configuration can be overridden again by project
|
|
125
|
+
// settings. Do not guess a writable global filename or change host settings.
|
|
126
|
+
if (filename !== undefined && filename !== "GEMINI.md" &&
|
|
127
|
+
!(Array.isArray(filename) && filename.includes("GEMINI.md"))) fail("custom_context_unsupported");
|
|
128
|
+
return { path: join(root, "GEMINI.md"), dedicated: false };
|
|
129
|
+
}
|
|
130
|
+
return { path: join(root, "rules", `halofy-${owner}.${connection.clientKind === "cursor" ? "mdc" : "md"}`), dedicated: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function render(bundle, owner, cursor) {
|
|
134
|
+
const begin = `${MARKER}${owner}:BEGIN -->`;
|
|
135
|
+
const end = `${MARKER}${owner}:END -->`;
|
|
136
|
+
const content = bundle.documents.map((doc) => `## ${doc.title}\n\n${doc.content}`).join("\n\n");
|
|
137
|
+
const text = `${cursor ? "---\nalwaysApply: true\n---\n" : ""}${begin}\n# Organization agent instructions\n\n${content}\n${end}\n`;
|
|
138
|
+
return { begin: Buffer.from(begin), end: Buffer.from(end), bytes: Buffer.from(text) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Writes only owned bytes. Any ambiguity leaves the host file unchanged. */
|
|
142
|
+
export async function applyInstructionBundle(connection, bundle, { root, beforeWrite, canActivate = async () => true } = {}) {
|
|
143
|
+
bundle = validateInstructionBundle(bundle, connection.namespace);
|
|
144
|
+
const owner = sha(`${connection.serverUrl}\0${connection.namespace}`).slice(0, 24);
|
|
145
|
+
const target = await targetFor(connection, owner);
|
|
146
|
+
if (!target) return { digest: bundle.digest, status: "unsupported", reason: "host_unsupported" };
|
|
147
|
+
const stateKey = sha(`${connection.clientKind}\0${connection.instructionProfile.root}\0${owner}`);
|
|
148
|
+
const statePath = join(resolve(root), "instructions", `${stateKey}.json`);
|
|
149
|
+
await safePath(statePath, { createParents: true });
|
|
150
|
+
// Profile-level lock remains the same if Codex switches its override file.
|
|
151
|
+
const lockPath = join(connection.instructionProfile.root, ".halofy-instructions.lock");
|
|
152
|
+
await safePath(lockPath, { createParents: true });
|
|
153
|
+
let lock;
|
|
154
|
+
try {
|
|
155
|
+
try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); }
|
|
156
|
+
catch (error) { if (error.code === "EEXIST") fail("sync_busy"); throw error; }
|
|
157
|
+
if (!(await canActivate())) fail("inactive_installation");
|
|
158
|
+
const initial = await snapshot(target.path);
|
|
159
|
+
const stateSnapshot = await snapshot(statePath);
|
|
160
|
+
let state;
|
|
161
|
+
try { state = stateSnapshot.exists ? JSON.parse(stateSnapshot.bytes.toString("utf8")) : null; } catch { fail("ownership_invalid"); }
|
|
162
|
+
if (state && (state.version !== 1 || state.path !== target.path || state.owner !== owner ||
|
|
163
|
+
typeof state.managedHash !== "string" || !HEX.test(state.managedHash))) fail("ownership_conflict");
|
|
164
|
+
if (state?.removed) {
|
|
165
|
+
if (target.dedicated && sha(initial.bytes) !== state.managedHash) fail("managed_content_changed");
|
|
166
|
+
if (!target.dedicated && initial.bytes.includes(Buffer.from(`${MARKER}${owner}:`))) fail("managed_content_changed");
|
|
167
|
+
if (bundle.documents.length === 0) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
|
|
168
|
+
state = null;
|
|
169
|
+
}
|
|
170
|
+
const rendered = render(bundle, owner, connection.clientKind === "cursor");
|
|
171
|
+
const bytes = initial.bytes;
|
|
172
|
+
const start = bytes.indexOf(rendered.begin);
|
|
173
|
+
const end = bytes.indexOf(rendered.end);
|
|
174
|
+
let prefix = bytes;
|
|
175
|
+
let suffix = Buffer.alloc(0);
|
|
176
|
+
let separator = "";
|
|
177
|
+
if (state) {
|
|
178
|
+
if (target.dedicated) {
|
|
179
|
+
if (!initial.exists || sha(bytes) !== state.managedHash) fail("managed_content_changed");
|
|
180
|
+
prefix = Buffer.alloc(0);
|
|
181
|
+
} else {
|
|
182
|
+
if (start < 0 || end < start || bytes.indexOf(rendered.begin, start + 1) !== -1 ||
|
|
183
|
+
bytes.indexOf(rendered.end, end + 1) !== -1) fail("markers_invalid");
|
|
184
|
+
const managedEnd = end + rendered.end.length + 1;
|
|
185
|
+
if (bytes[managedEnd - 1] !== 10 || sha(bytes.subarray(start, managedEnd)) !== state.managedHash) fail("managed_content_changed");
|
|
186
|
+
separator = state.separator === "\n\n" ? state.separator : "";
|
|
187
|
+
const prefixEnd = start - Buffer.byteLength(separator);
|
|
188
|
+
if (prefixEnd < 0 || bytes.subarray(prefixEnd, start).toString() !== separator) fail("managed_content_changed");
|
|
189
|
+
prefix = bytes.subarray(0, prefixEnd);
|
|
190
|
+
suffix = bytes.subarray(managedEnd);
|
|
191
|
+
}
|
|
192
|
+
} else if (target.dedicated && initial.exists && initial.bytes.length > 0 || start >= 0 || end >= 0 || bytes.includes(Buffer.from(MARKER))) fail("unowned_content");
|
|
193
|
+
// Refuse malformed owned-looking markers outside the verified region too.
|
|
194
|
+
if (prefix.includes(Buffer.from(MARKER)) || suffix.includes(Buffer.from(MARKER))) fail("markers_invalid");
|
|
195
|
+
const removing = bundle.documents.length === 0;
|
|
196
|
+
if (removing && !state) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
|
|
197
|
+
if (!state && !target.dedicated) separator = prefix.length ? "\n\n" : "";
|
|
198
|
+
const updated = removing ? Buffer.concat([prefix, suffix]) : Buffer.concat([prefix, Buffer.from(separator), rendered.bytes, suffix]);
|
|
199
|
+
const next = { version: 1, owner, path: target.path, managedHash: sha(rendered.bytes), separator, digest: bundle.digest };
|
|
200
|
+
if (!updated.equals(bytes)) {
|
|
201
|
+
// Backup lives outside the host's rules directory, so it cannot be loaded
|
|
202
|
+
// as a second rule. Never send backup or filesystem paths to the server.
|
|
203
|
+
const backup = `${statePath}.${Date.now()}.${randomBytes(6).toString("hex")}.bak`;
|
|
204
|
+
await atomic(backup, bytes, await snapshot(backup));
|
|
205
|
+
await beforeWrite?.();
|
|
206
|
+
if (!(await canActivate())) fail("inactive_installation");
|
|
207
|
+
if ((await targetFor(connection, owner))?.path !== target.path) fail("host_profile_changed");
|
|
208
|
+
await atomic(target.path, updated, initial);
|
|
209
|
+
}
|
|
210
|
+
if (removing) {
|
|
211
|
+
// Shared empty files remain to avoid a delete race with personal edits.
|
|
212
|
+
// Dedicated empty files are harmless and no longer include instructions.
|
|
213
|
+
await atomic(statePath, Buffer.from(`${JSON.stringify({ ...next, removed: true, managedHash: sha(updated) })}\n`), stateSnapshot);
|
|
214
|
+
} else {
|
|
215
|
+
await atomic(statePath, Buffer.from(`${JSON.stringify(next)}\n`), stateSnapshot);
|
|
216
|
+
}
|
|
217
|
+
return { digest: bundle.digest, status: "pending_restart", reason: removing ? "managed_content_removed" : "host_reload_required" };
|
|
218
|
+
} finally {
|
|
219
|
+
if (lock) { await lock.close(); await safePath(lockPath); await unlink(lockPath); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A confirmed reconnect retires only verified instruction bytes owned by its
|
|
224
|
+
* predecessors. The complete chain is validated before the first removal.
|
|
225
|
+
* Same-owner retries share the manifest and keep their current instruction copy. */
|
|
226
|
+
async function retirePredecessorInstructions(connection, root) {
|
|
227
|
+
const sameOwner = (left, right) => left.serverUrl === right.serverUrl && left.namespace === right.namespace &&
|
|
228
|
+
left.clientKind === right.clientKind && left.instructionProfile?.root === right.instructionProfile?.root;
|
|
229
|
+
const store = new ConnectionStore(root);
|
|
230
|
+
const visited = new Set([connection.installationId]);
|
|
231
|
+
const predecessors = [];
|
|
232
|
+
let previous = connection.previousInstallationId;
|
|
233
|
+
while (previous) {
|
|
234
|
+
if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous) || visited.has(previous) || visited.size > 64) fail("predecessor_invalid");
|
|
235
|
+
visited.add(previous);
|
|
236
|
+
const prior = await readJson(store.path(previous));
|
|
237
|
+
if (!prior || prior.clientKind !== connection.clientKind) fail("predecessor_invalid");
|
|
238
|
+
predecessors.push(prior);
|
|
239
|
+
previous = prior.previousInstallationId;
|
|
240
|
+
}
|
|
241
|
+
const retired = [];
|
|
242
|
+
for (const prior of predecessors) {
|
|
243
|
+
if (!prior.instructionProfile || sameOwner(prior, connection) || retired.some((entry) => sameOwner(entry, prior))) continue;
|
|
244
|
+
const empty = { schemaVersion: 1, namespace: prior.namespace, digest: sha("[]"), documents: [] };
|
|
245
|
+
await applyInstructionBundle(prior, empty, { root, canActivate: () => isActiveInstallation(connection, root) });
|
|
246
|
+
retired.push(prior);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
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 }) } = {}) {
|
|
252
|
+
let receipt = { digest: sha("[]"), status: "unavailable", reason: "fetch_unavailable" };
|
|
253
|
+
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
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -8,19 +8,23 @@ import {
|
|
|
8
8
|
import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
|
|
9
9
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
10
10
|
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
11
|
+
import { syncAgentInstructions } from "./instructions.mjs";
|
|
11
12
|
import { RUNTIME_VERSION } from "./version.mjs";
|
|
12
13
|
|
|
13
14
|
export class LifecycleRuntime {
|
|
14
15
|
constructor(connection, {
|
|
15
16
|
root,
|
|
16
17
|
transport = new SignedRuntimeTransport(connection),
|
|
18
|
+
instructionTransport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }),
|
|
17
19
|
maxBatchEvents = 100,
|
|
18
20
|
maxBatchBytes = 1024 * 1024,
|
|
19
21
|
commitThreshold = 20,
|
|
20
22
|
} = {}) {
|
|
21
23
|
if (!root) throw new Error("runtime storage root is required");
|
|
22
24
|
this.connection = connection;
|
|
25
|
+
this.root = root;
|
|
23
26
|
this.transport = transport;
|
|
27
|
+
this.instructionTransport = instructionTransport;
|
|
24
28
|
const connectionRoot = join(root, connection.installationId);
|
|
25
29
|
this.queue = new BoundedEncryptedQueue(connectionRoot);
|
|
26
30
|
this.cursors = new CursorStore(connectionRoot);
|
|
@@ -32,6 +36,13 @@ export class LifecycleRuntime {
|
|
|
32
36
|
this.sessions = new Map();
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
async syncInstructions() {
|
|
40
|
+
// Legacy bindings have no consent/profile snapshot. Reinstall the reviewed
|
|
41
|
+
// package to enable global instruction management.
|
|
42
|
+
if (!this.connection.instructionProfile) return { status: "unsupported", reason: "reinstall_required" };
|
|
43
|
+
return syncAgentInstructions(this.connection, { root: this.root, transport: this.instructionTransport });
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
sessionHash(hostSessionId) {
|
|
36
47
|
return deriveSessionHash({
|
|
37
48
|
installationId: this.connection.installationId,
|
package/src/transport.mjs
CHANGED
|
@@ -105,6 +105,8 @@ export class SignedRuntimeTransport {
|
|
|
105
105
|
skillDownload(skillId) {
|
|
106
106
|
return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
|
|
107
107
|
}
|
|
108
|
+
instructions() { return this.request("/v1/agent-runtime/instructions", { method: "GET", maxResponseBytes: 64 * 1024 }); }
|
|
109
|
+
instructionStatus(body) { return this.request("/v1/agent-runtime/instructions/status", { body, maxResponseBytes: 16 * 1024 }); }
|
|
108
110
|
|
|
109
111
|
openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
|
|
110
112
|
appendEvents(sessionId, events) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/events`, { body: { events } }); }
|
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.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-
|
|
2
|
+
export const INSTALLER_VERSION = "0.10.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.10.0";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";
|