@halofy/agent-connect 0.8.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 +83 -1
- package/package.json +1 -1
- package/src/active.mjs +8 -0
- package/src/claude-hook.mjs +16 -53
- package/src/context-sync.mjs +34 -22
- package/src/delivery-sync.mjs +108 -0
- package/src/host-hook.mjs +11 -6
- package/src/install.mjs +20 -21
- package/src/installer-cli.mjs +23 -43
- package/src/instructions.mjs +266 -0
- package/src/predecessor-sync.mjs +33 -0
- package/src/runtime.mjs +11 -0
- package/src/skills-sync.mjs +101 -10
- package/src/storage.mjs +20 -2
- package/src/transport.mjs +8 -0
- package/src/version.mjs +3 -3
package/src/installer-cli.mjs
CHANGED
|
@@ -10,16 +10,15 @@ import {
|
|
|
10
10
|
installLocalConnection,
|
|
11
11
|
installRuntimeBundle,
|
|
12
12
|
localMcpSnippet,
|
|
13
|
-
|
|
14
|
-
syncInstalledContext,
|
|
13
|
+
syncInstalledDelivery,
|
|
15
14
|
} from "./install.mjs";
|
|
16
15
|
import { configureClaudeProject } from "./claude-config.mjs";
|
|
17
16
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
18
17
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
19
18
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
19
|
+
import { instructionProfile } from "./instructions.mjs";
|
|
20
20
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
21
|
-
import {
|
|
22
|
-
import { describeContextSync } from "./context-sync.mjs";
|
|
21
|
+
import { managedSkillsDirectory } from "./skills-sync.mjs";
|
|
23
22
|
|
|
24
23
|
const CLAIM_PATTERN = /^hsc_[A-Za-z0-9_-]{43}$/;
|
|
25
24
|
|
|
@@ -197,9 +196,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
197
196
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
198
197
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
199
198
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.",
|
|
203
202
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
204
203
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
205
204
|
].join("\n");
|
|
@@ -252,8 +251,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
252
251
|
lines.push(
|
|
253
252
|
"",
|
|
254
253
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
255
|
-
"This reads and writes
|
|
256
|
-
"
|
|
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.",
|
|
257
257
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
258
258
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
259
259
|
);
|
|
@@ -356,6 +356,7 @@ async function runAllInstaller(input, {
|
|
|
356
356
|
root,
|
|
357
357
|
fetchImpl,
|
|
358
358
|
sendHeartbeat: false,
|
|
359
|
+
instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
359
360
|
});
|
|
360
361
|
const configured = await configureHost(host.clientKind, {
|
|
361
362
|
projectRoot: input.projectRoot,
|
|
@@ -371,18 +372,8 @@ async function runAllInstaller(input, {
|
|
|
371
372
|
} catch {
|
|
372
373
|
heartbeat = false;
|
|
373
374
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
let skills = null;
|
|
377
|
-
try {
|
|
378
|
-
skills = await syncInstalledSkills({
|
|
379
|
-
installationId: installed.installationId, root, fetchImpl,
|
|
380
|
-
...(skillsHome ? { home: skillsHome } : {}),
|
|
381
|
-
});
|
|
382
|
-
} catch {
|
|
383
|
-
skills = null;
|
|
384
|
-
}
|
|
385
|
-
const context = await syncContextForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
|
|
375
|
+
const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
|
|
376
|
+
const { skills, context } = delivery;
|
|
386
377
|
results.push({
|
|
387
378
|
context,
|
|
388
379
|
clientKind: host.clientKind,
|
|
@@ -395,6 +386,7 @@ async function runAllInstaller(input, {
|
|
|
395
386
|
conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
|
|
396
387
|
: null,
|
|
397
388
|
proofStorage: installed.proofStorage,
|
|
389
|
+
instructions: installed.instructions,
|
|
398
390
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
399
391
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
400
392
|
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
@@ -460,6 +452,7 @@ export async function runInstaller(argv, {
|
|
|
460
452
|
root,
|
|
461
453
|
fetchImpl,
|
|
462
454
|
sendHeartbeat: false,
|
|
455
|
+
instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
463
456
|
});
|
|
464
457
|
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
465
458
|
const common = {
|
|
@@ -479,20 +472,8 @@ export async function runInstaller(argv, {
|
|
|
479
472
|
} catch {
|
|
480
473
|
heartbeat = false;
|
|
481
474
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
// restarts the host. A skills problem is reported, never fatal.
|
|
485
|
-
let skills = null;
|
|
486
|
-
try {
|
|
487
|
-
skills = await syncInstalledSkills({
|
|
488
|
-
installationId: installed.installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
|
|
489
|
-
});
|
|
490
|
-
output.write(`${describeSkillSync(skills)}\n`);
|
|
491
|
-
} catch (error) {
|
|
492
|
-
skills = null;
|
|
493
|
-
output.write(`skills: unavailable (${error?.code || "runtime_unavailable"})\n`);
|
|
494
|
-
}
|
|
495
|
-
const context = await syncContextForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
|
|
475
|
+
const delivery = await syncDeliveryForInstall({ installationId: installed.installationId, root, fetchImpl, skillsHome, output });
|
|
476
|
+
const { skills, context } = delivery;
|
|
496
477
|
return {
|
|
497
478
|
context,
|
|
498
479
|
status: heartbeat ? "configured_heartbeat_verified" : "configured_heartbeat_unavailable",
|
|
@@ -501,6 +482,7 @@ export async function runInstaller(argv, {
|
|
|
501
482
|
installerVersion: INSTALLER_VERSION,
|
|
502
483
|
publishedPackage: true,
|
|
503
484
|
projectConfigured: true,
|
|
485
|
+
instructions: installed.instructions,
|
|
504
486
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
505
487
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
506
488
|
mcpConfiguration: localMcpSnippet({
|
|
@@ -516,15 +498,13 @@ export async function runInstaller(argv, {
|
|
|
516
498
|
};
|
|
517
499
|
}
|
|
518
500
|
|
|
519
|
-
async function
|
|
501
|
+
async function syncDeliveryForInstall({ installationId, root, fetchImpl, skillsHome, output }) {
|
|
520
502
|
try {
|
|
521
|
-
|
|
522
|
-
installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}),
|
|
503
|
+
return await syncInstalledDelivery({
|
|
504
|
+
installationId, root, fetchImpl, ...(skillsHome ? { home: skillsHome } : {}), stderr: output,
|
|
523
505
|
});
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
output.write(`policies and knowledge: unavailable (${error?.code || "runtime_unavailable"})\n`);
|
|
528
|
-
return { complete: false, errors: [{ code: error?.code || "runtime_unavailable" }] };
|
|
506
|
+
} catch {
|
|
507
|
+
output.write("delivery: unavailable\n");
|
|
508
|
+
return { skills: null, context: { complete: false, errors: [{ code: "runtime_unavailable" }] } };
|
|
529
509
|
}
|
|
530
510
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ConnectionStore, readJson, safeDirectory } from "./storage.mjs";
|
|
3
|
+
import { syncManagedContext, withContextLock } from "./context-sync.mjs";
|
|
4
|
+
import { retireManagedSkills } from "./skills-sync.mjs";
|
|
5
|
+
|
|
6
|
+
/** Every delivery attempt must finish reconnect cleanup, including hook retries
|
|
7
|
+
* after an interrupted installer. This obligation travels in the connection. */
|
|
8
|
+
export async function retirePredecessorContent({ connection, root, ...options }) {
|
|
9
|
+
const store = new ConnectionStore(root);
|
|
10
|
+
const visited = new Set([connection.installationId]);
|
|
11
|
+
let previous = connection.previousInstallationId;
|
|
12
|
+
while (previous) {
|
|
13
|
+
if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous)
|
|
14
|
+
|| visited.has(previous) || visited.size > 64) {
|
|
15
|
+
throw Object.assign(new Error("invalid context predecessor chain"), { code: "context_predecessor_invalid" });
|
|
16
|
+
}
|
|
17
|
+
visited.add(previous);
|
|
18
|
+
const prior = await readJson(store.path(previous));
|
|
19
|
+
if (prior && prior.clientKind !== connection.clientKind) {
|
|
20
|
+
throw Object.assign(new Error("context predecessor host mismatch"), { code: "context_predecessor_invalid" });
|
|
21
|
+
}
|
|
22
|
+
const priorConnection = { installationId: previous, clientKind: connection.clientKind };
|
|
23
|
+
const priorState = join(root, `delivery-${previous}`);
|
|
24
|
+
await safeDirectory(priorState, true);
|
|
25
|
+
await withContextLock(priorState, async () => {
|
|
26
|
+
const retired = await syncManagedContext({ ...options, root, connection: priorConnection,
|
|
27
|
+
transport: { contextPage: async () => ({ items: [], nextCursor: null }) } });
|
|
28
|
+
if (!retired.complete && !retired.removed) throw Object.assign(new Error("predecessor cleanup incomplete"), { code: "context_predecessor_incomplete" });
|
|
29
|
+
await retireManagedSkills({ ...options, root, connection: priorConnection });
|
|
30
|
+
});
|
|
31
|
+
previous = prior?.previousInstallationId;
|
|
32
|
+
}
|
|
33
|
+
}
|
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,
|