@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/skills-sync.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, rename,
|
|
2
|
+
import { mkdir, readFile, rename, lstat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { isActiveInstallation } from "./active.mjs";
|
|
6
|
+
import { ensurePrivateDirectory, readJson, safeDirectory, writePrivateFile } from "./storage.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Badge-delivered skills (CONTRACTS-S16-SKILL-TRACKING D37–D39 on the signed
|
|
@@ -62,7 +63,7 @@ function safeKey(skillKey) {
|
|
|
62
63
|
|
|
63
64
|
async function pathState(path) {
|
|
64
65
|
try {
|
|
65
|
-
return await
|
|
66
|
+
return await lstat(path);
|
|
66
67
|
} catch (error) {
|
|
67
68
|
if (error?.code === "ENOENT") return null;
|
|
68
69
|
throw error;
|
|
@@ -80,6 +81,9 @@ async function readText(path) {
|
|
|
80
81
|
|
|
81
82
|
/** Move a managed copy aside with its bytes intact; never `rm`. */
|
|
82
83
|
async function quarantine(skillsRoot, quarantineRoot, skillKey, now) {
|
|
84
|
+
if (!safeKey(skillKey)) throw new Error("invalid managed skill key");
|
|
85
|
+
await safeDirectory(skillsRoot);
|
|
86
|
+
await safeDirectory(quarantineRoot, true);
|
|
83
87
|
const from = join(skillsRoot, skillKey);
|
|
84
88
|
if (!(await pathState(from))) return null;
|
|
85
89
|
await ensurePrivateDirectory(quarantineRoot);
|
|
@@ -99,10 +103,15 @@ async function isManaged(skillsRoot, skillKey, manifestEntry) {
|
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export async function loadSkillsManifest(root, installationId) {
|
|
106
|
+
if (typeof installationId !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(installationId)) throw new Error("invalid installation");
|
|
107
|
+
await safeDirectory(root, true);
|
|
108
|
+
const manifestFile = await pathState(skillsManifestPath(root, installationId));
|
|
109
|
+
if (manifestFile && (!manifestFile.isFile() || manifestFile.isSymbolicLink())) throw new Error("unsafe skill manifest");
|
|
102
110
|
const manifest = await readJson(skillsManifestPath(root, installationId), null);
|
|
103
|
-
if (!
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
if (!manifestFile) return { version: 1, installationId, installs: [], lastCheckinAt: null };
|
|
112
|
+
if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.installs)) throw new Error("invalid skill manifest");
|
|
113
|
+
if (manifest.installationId !== installationId || manifest.installs.some((entry) =>
|
|
114
|
+
!safeKey(entry?.skillKey) || typeof entry.skillId !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha))) throw new Error("invalid skill manifest");
|
|
106
115
|
return manifest;
|
|
107
116
|
}
|
|
108
117
|
|
|
@@ -126,6 +135,7 @@ export async function syncManagedSkills({
|
|
|
126
135
|
supported: skillsRoot !== null,
|
|
127
136
|
skillsRoot,
|
|
128
137
|
checkedIn: false,
|
|
138
|
+
complete: false,
|
|
129
139
|
installed: [],
|
|
130
140
|
updated: [],
|
|
131
141
|
quarantined: [],
|
|
@@ -134,30 +144,46 @@ export async function syncManagedSkills({
|
|
|
134
144
|
errors: [],
|
|
135
145
|
};
|
|
136
146
|
if (!skillsRoot) return summary;
|
|
147
|
+
await safeDirectory(skillsRoot, true);
|
|
137
148
|
|
|
138
149
|
const manifestPath = skillsManifestPath(root, connection.installationId);
|
|
139
150
|
const quarantineRoot = join(root, "skills-quarantine", connection.installationId);
|
|
140
151
|
const manifest = await loadSkillsManifest(root, connection.installationId);
|
|
141
|
-
const byId = new Map(manifest.installs.map((entry) => [entry.skillId, entry]));
|
|
142
152
|
const current = now();
|
|
143
153
|
|
|
154
|
+
const active = () => isActiveInstallation(connection, root);
|
|
155
|
+
if (!(await active())) {
|
|
156
|
+
summary.errors.push({ stage: "checkin", code: "inactive_installation" });
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
144
159
|
let response;
|
|
145
160
|
try {
|
|
146
161
|
response = await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
|
|
162
|
+
if (!response || !Array.isArray(response.skills) || !Array.isArray(response.items)
|
|
163
|
+
|| response.skills.some((skill) => !safeKey(skill?.skillKey) || typeof skill.skillId !== "string"
|
|
164
|
+
|| !/^[a-f0-9]{64}$/.test(skill.sha))
|
|
165
|
+
|| new Set(response.skills.map((skill) => skill.skillId)).size !== response.skills.length
|
|
166
|
+
|| new Set(response.skills.map((skill) => skill.skillKey)).size !== response.skills.length) {
|
|
167
|
+
throw Object.assign(new Error("invalid skill discovery"), { code: "invalid_skill_discovery" });
|
|
168
|
+
}
|
|
169
|
+
if (!(await active())) throw Object.assign(new Error("inactive installation"), { code: "inactive_installation" });
|
|
147
170
|
summary.checkedIn = true;
|
|
148
171
|
} catch (error) {
|
|
149
172
|
summary.errors.push({ stage: "checkin", code: error?.code || "runtime_unavailable" });
|
|
173
|
+
if (error?.code === "inactive_installation") return summary;
|
|
150
174
|
const last = manifest.lastCheckinAt ? Date.parse(manifest.lastCheckinAt) : NaN;
|
|
151
175
|
if (Number.isFinite(last) && current.getTime() - last > STALE_AFTER_MS) {
|
|
176
|
+
const failedWithdrawals = [];
|
|
152
177
|
for (const entry of manifest.installs) {
|
|
153
178
|
try {
|
|
154
179
|
const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
|
|
155
180
|
if (moved) summary.quarantined.push({ skillKey: entry.skillKey, reason: "stale_checkin", to: moved });
|
|
156
181
|
} catch (fsError) {
|
|
182
|
+
failedWithdrawals.push(entry);
|
|
157
183
|
summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: fsError?.code || "fs_error" });
|
|
158
184
|
}
|
|
159
185
|
}
|
|
160
|
-
manifest.installs =
|
|
186
|
+
manifest.installs = failedWithdrawals;
|
|
161
187
|
await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
162
188
|
}
|
|
163
189
|
return summary;
|
|
@@ -172,6 +198,7 @@ export async function syncManagedSkills({
|
|
|
172
198
|
// lists that the badge no longer resolves.
|
|
173
199
|
const verdicts = new Map((Array.isArray(response?.items) ? response.items : []).map((item) => [item.skillId, item]));
|
|
174
200
|
const survivors = [];
|
|
201
|
+
const failedWithdrawals = [];
|
|
175
202
|
for (const entry of manifest.installs) {
|
|
176
203
|
const verdict = verdicts.get(entry.skillId);
|
|
177
204
|
const status = verdict?.status;
|
|
@@ -183,6 +210,7 @@ export async function syncManagedSkills({
|
|
|
183
210
|
} catch (error) {
|
|
184
211
|
summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: error?.code || "fs_error" });
|
|
185
212
|
survivors.push(entry);
|
|
213
|
+
failedWithdrawals.push(entry);
|
|
186
214
|
}
|
|
187
215
|
}
|
|
188
216
|
manifest.installs = survivors;
|
|
@@ -196,11 +224,21 @@ export async function syncManagedSkills({
|
|
|
196
224
|
entry.skillId = verdict.currentSkillId;
|
|
197
225
|
}
|
|
198
226
|
}
|
|
199
|
-
const next = [];
|
|
227
|
+
const next = [...failedWithdrawals];
|
|
200
228
|
for (const skill of wanted.values()) {
|
|
201
229
|
const entry = manifest.installs.find((candidate) => candidate.skillId === skill.skillId);
|
|
202
230
|
const dir = join(skillsRoot, skill.skillKey);
|
|
203
231
|
const file = join(dir, "SKILL.md");
|
|
232
|
+
if (!(await active())) {
|
|
233
|
+
summary.errors.push({ stage: "download", code: "inactive_installation" });
|
|
234
|
+
return summary;
|
|
235
|
+
}
|
|
236
|
+
const directoryEntry = await pathState(dir);
|
|
237
|
+
const fileEntry = await pathState(file);
|
|
238
|
+
if (directoryEntry?.isSymbolicLink() || fileEntry?.isSymbolicLink()) {
|
|
239
|
+
summary.conflicts.push({ skillKey: skill.skillKey, reason: "unsafe_path" });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
204
242
|
const onDisk = await readText(file);
|
|
205
243
|
const intact = entry && typeof onDisk === "string" && digest(onDisk) === entry.fileSha256;
|
|
206
244
|
if (entry && entry.sha === skill.sha && intact) {
|
|
@@ -216,7 +254,12 @@ export async function syncManagedSkills({
|
|
|
216
254
|
const exported = await transport.skillDownload(skill.skillId);
|
|
217
255
|
const content = typeof exported?.content === "string" ? exported.content : null;
|
|
218
256
|
if (!content) throw Object.assign(new Error("skill export was empty"), { code: "empty_export" });
|
|
219
|
-
|
|
257
|
+
if (!content.startsWith("---\n") || !content.split("\n---\n")[0].split("\n").includes(`content_sha256: ${skill.sha}`)) {
|
|
258
|
+
throw Object.assign(new Error("skill digest mismatch"), { code: "skill_digest_mismatch" });
|
|
259
|
+
}
|
|
260
|
+
if (!(await active())) throw Object.assign(new Error("inactive installation"), { code: "inactive_installation" });
|
|
261
|
+
await safeDirectory(dir, true);
|
|
262
|
+
if ((await pathState(file))?.isSymbolicLink()) throw new Error("unsafe skill file");
|
|
220
263
|
await writePrivateFile(file, content);
|
|
221
264
|
const record = {
|
|
222
265
|
skillId: skill.skillId,
|
|
@@ -248,6 +291,12 @@ export async function syncManagedSkills({
|
|
|
248
291
|
summary.errors.push({ stage: "ack", code: error?.code || "runtime_unavailable" });
|
|
249
292
|
}
|
|
250
293
|
}
|
|
294
|
+
summary.complete = summary.checkedIn && !summary.errors.length && !summary.conflicts.length
|
|
295
|
+
&& manifest.installs.length === wanted.size && await active();
|
|
296
|
+
if (summary.complete) {
|
|
297
|
+
try { Object.defineProperty(summary, "tuples", { value: await readManagedSkillTuples({ connection, root, home, env }) }); }
|
|
298
|
+
catch { summary.complete = false; }
|
|
299
|
+
}
|
|
251
300
|
return summary;
|
|
252
301
|
}
|
|
253
302
|
|
|
@@ -264,3 +313,45 @@ export function describeSkillSync(summary) {
|
|
|
264
313
|
if (!summary.checkedIn) parts.unshift("check-in unavailable");
|
|
265
314
|
return `skills (${basename(dirname(summary.skillsRoot))}/${basename(summary.skillsRoot)}): ${parts.join(", ") || "nothing to do"}`;
|
|
266
315
|
}
|
|
316
|
+
|
|
317
|
+
/** Verify exported definition bytes, not just claimed frontmatter/manifest SHAs. */
|
|
318
|
+
export async function readManagedSkillTuples({ connection, root, home = homedir(), env = process.env }) {
|
|
319
|
+
const skillsRoot = managedSkillsDirectory(connection.clientKind, { home, env });
|
|
320
|
+
if (skillsRoot) await safeDirectory(skillsRoot);
|
|
321
|
+
await safeDirectory(root);
|
|
322
|
+
const manifestEntry = await pathState(skillsManifestPath(root, connection.installationId));
|
|
323
|
+
if (!skillsRoot || !manifestEntry?.isFile() || manifestEntry.isSymbolicLink()) throw new Error("missing skill evidence");
|
|
324
|
+
const manifest = await loadSkillsManifest(root, connection.installationId);
|
|
325
|
+
if (manifest.installationId !== connection.installationId || !manifest.lastCheckinAt) throw new Error("incomplete skill evidence");
|
|
326
|
+
for (const entry of manifest.installs) {
|
|
327
|
+
if (!safeKey(entry.skillKey) || typeof entry.skillId !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha)) throw new Error("invalid skill evidence");
|
|
328
|
+
const dir = join(skillsRoot, entry.skillKey), file = join(dir, "SKILL.md");
|
|
329
|
+
if ((await pathState(dir))?.isSymbolicLink() || (await pathState(file))?.isSymbolicLink()) throw new Error("unsafe skill evidence");
|
|
330
|
+
const content = await readText(file);
|
|
331
|
+
const separator = content?.indexOf("\n---\n\n");
|
|
332
|
+
// The export wrapper adds exactly one blank line and one terminal newline.
|
|
333
|
+
const definition = separator >= 0 && content.endsWith("\n") ? content.slice(separator + 6, -1) : null;
|
|
334
|
+
if (definition === null || digest(content) !== entry.fileSha256 || digest(definition) !== entry.sha) throw new Error("modified skill evidence");
|
|
335
|
+
}
|
|
336
|
+
return manifest.installs.map(({ skillId, skillKey, sha }) => [skillId, skillKey, sha]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Retire a reconnect predecessor's managed discovery before activating its replacement.
|
|
340
|
+
* Move the owned directory intact, including employee edits, outside discovery;
|
|
341
|
+
* never delete or follow its contents. An unowned collision remains untouched. */
|
|
342
|
+
export async function retireManagedSkills({ connection, root, home = homedir(), env = process.env, now = () => new Date() }) {
|
|
343
|
+
const skillsRoot = managedSkillsDirectory(connection.clientKind, { home, env });
|
|
344
|
+
if (!skillsRoot) return;
|
|
345
|
+
const manifest = await loadSkillsManifest(root, connection.installationId);
|
|
346
|
+
if (!manifest.installs.length) return;
|
|
347
|
+
await safeDirectory(skillsRoot);
|
|
348
|
+
for (const entry of manifest.installs) {
|
|
349
|
+
const dir = join(skillsRoot, entry.skillKey);
|
|
350
|
+
const disk = await pathState(dir);
|
|
351
|
+
if (!disk) continue;
|
|
352
|
+
if (disk.isSymbolicLink() || !disk.isDirectory()) throw new Error("unsafe predecessor skill");
|
|
353
|
+
await quarantine(skillsRoot, join(root, "skills-quarantine", connection.installationId), entry.skillKey, now());
|
|
354
|
+
}
|
|
355
|
+
manifest.installs = [];
|
|
356
|
+
await writePrivateFile(skillsManifestPath(root, connection.installationId), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
357
|
+
}
|
package/src/storage.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { access, chmod, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { access, chmod, mkdir, lstat, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
|
|
@@ -132,3 +132,21 @@ export class ConnectionStore {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/** Refuse symlinks at every ancestor before touching managed runtime paths. */
|
|
137
|
+
export async function safeDirectory(path, create = false) {
|
|
138
|
+
const absolute = resolve(path);
|
|
139
|
+
let current = parse(absolute).root;
|
|
140
|
+
for (const part of absolute.slice(current.length).split(/[\\/]/).filter(Boolean)) {
|
|
141
|
+
current = join(current, part);
|
|
142
|
+
let entry;
|
|
143
|
+
try { entry = await lstat(current); }
|
|
144
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
145
|
+
if (!entry && create) {
|
|
146
|
+
try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error?.code !== "EEXIST") throw error; }
|
|
147
|
+
entry = await lstat(current);
|
|
148
|
+
}
|
|
149
|
+
if (!entry) throw Object.assign(new Error("directory_missing"), { code: "directory_missing" });
|
|
150
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw Object.assign(new Error("unsafe_directory"), { code: "unsafe_directory" });
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/transport.mjs
CHANGED
|
@@ -90,6 +90,12 @@ export class SignedRuntimeTransport {
|
|
|
90
90
|
|
|
91
91
|
/** Badge skill check-in beacon: exactly the installed {skillId, sha} pairs (D39). */
|
|
92
92
|
skillsCheckin(items) { return this.request("/v1/agent-runtime/skills/checkin", { body: { items } }); }
|
|
93
|
+
deliveryCheck({ refresh = false } = {}) {
|
|
94
|
+
return this.request("/v1/agent-runtime/delivery/check", { body: refresh ? { refresh: true } : {}, maxResponseBytes: 16 * 1024 });
|
|
95
|
+
}
|
|
96
|
+
deliveryReceipt(receipt) {
|
|
97
|
+
return this.request("/v1/agent-runtime/delivery/receipt", { body: receipt, maxResponseBytes: 16 * 1024 });
|
|
98
|
+
}
|
|
93
99
|
contextPage(cursor) {
|
|
94
100
|
return this.request("/v1/agent-runtime/context", {
|
|
95
101
|
body: cursor === undefined ? {} : { cursor }, maxResponseBytes: 8 * 1024 * 1024,
|
|
@@ -99,6 +105,8 @@ export class SignedRuntimeTransport {
|
|
|
99
105
|
skillDownload(skillId) {
|
|
100
106
|
return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
|
|
101
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 }); }
|
|
102
110
|
|
|
103
111
|
openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
|
|
104
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";
|