agentwheel 0.20.19 → 0.21.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.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/transport/run-as-worker.ts
4
+ import { lstat, readdir as readdir2, readFile as readFile2, realpath } from "fs/promises";
5
+ import { isAbsolute, join as join2, parse, relative as relative2, resolve, sep } from "path";
6
+
7
+ // src/utils/fs.ts
8
+ import { createHash } from "crypto";
9
+ import {
10
+ copyFile,
11
+ cp,
12
+ mkdir,
13
+ readdir,
14
+ readFile,
15
+ rename,
16
+ rm,
17
+ stat,
18
+ writeFile
19
+ } from "fs/promises";
20
+ import { dirname, join, relative } from "path";
21
+ var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", "__pycache__", ".DS_Store"]);
22
+ var IGNORED_SUFFIXES = [".pyc", ".pyo"];
23
+ async function hashPath(path) {
24
+ const stats = await stat(path);
25
+ if (stats.isFile()) {
26
+ const content = await readFile(path);
27
+ return createHash("sha256").update("file\0").update(content).digest("hex");
28
+ }
29
+ if (!stats.isDirectory()) {
30
+ throw new Error(`Unsupported path kind: ${path}`);
31
+ }
32
+ const hash = createHash("sha256").update("dir\0");
33
+ const files = await listFiles(path);
34
+ for (const file of files) {
35
+ hash.update(relative(path, file).replaceAll("\\", "/")).update("\0");
36
+ hash.update(await hashPath(file)).update("\0");
37
+ }
38
+ return hash.digest("hex");
39
+ }
40
+ async function listFiles(root) {
41
+ const out = [];
42
+ async function walk(dir) {
43
+ const entries = await readdir(dir, { withFileTypes: true });
44
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
45
+ if (isIgnoredGeneratedEntry(entry.name)) continue;
46
+ const full = join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ await walk(full);
49
+ } else if (entry.isFile()) {
50
+ out.push(full);
51
+ }
52
+ }
53
+ }
54
+ await walk(root);
55
+ return out;
56
+ }
57
+ function isIgnoredGeneratedEntry(name) {
58
+ return IGNORED_ENTRY_NAMES.has(name) || IGNORED_SUFFIXES.some((suffix) => name.endsWith(suffix));
59
+ }
60
+
61
+ // src/transport/run-as-worker.ts
62
+ async function containedReadPath(root, requested) {
63
+ if (!isAbsolute(root) || resolve(root) !== root || !requested || requested.includes("\0") || requested.includes("\\") || isAbsolute(requested) || requested !== "." && requested.split(sep).some((part) => part === ".." || part === "." || part === "")) {
64
+ throw new Error("Run-as read path is not a contained relative path.");
65
+ }
66
+ let current = parse(root).root;
67
+ for (const part of root.slice(current.length).split(sep).filter(Boolean)) {
68
+ current = join2(current, part);
69
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("Run-as root contains a symlink.");
70
+ }
71
+ if (await realpath(root) !== root) throw new Error("Run-as root changed.");
72
+ if (requested === ".") return root;
73
+ current = root;
74
+ for (const part of requested.split(sep)) {
75
+ current = join2(current, part);
76
+ try {
77
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("Run-as path contains a symlink.");
78
+ } catch (error) {
79
+ if (error.code === "ENOENT") break;
80
+ throw error;
81
+ }
82
+ }
83
+ const rel = relative2(root, current);
84
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("Run-as path escapes target root.");
85
+ return current;
86
+ }
87
+ async function rejectNestedLinks(path) {
88
+ const stat2 = await lstat(path);
89
+ if (stat2.isSymbolicLink()) throw new Error("Run-as read path contains a symlink.");
90
+ if (!stat2.isDirectory()) return;
91
+ for (const entry of await readdir2(path)) await rejectNestedLinks(join2(path, entry));
92
+ }
93
+ async function executeRead(root, request) {
94
+ if (request.version !== 1 || !["pathExists", "hashPath", "readFile", "listDir"].includes(request.operation)) {
95
+ throw new Error("Unsupported run-as read protocol request.");
96
+ }
97
+ const path = await containedReadPath(root, request.path);
98
+ let exists = true;
99
+ try {
100
+ await lstat(path);
101
+ } catch (error) {
102
+ if (error.code !== "ENOENT") throw error;
103
+ exists = false;
104
+ }
105
+ if (request.operation === "pathExists") return exists;
106
+ if (request.operation === "listDir" && !exists) return [];
107
+ if (!exists) throw new Error("Run-as target path does not exist.");
108
+ if (request.operation === "hashPath") {
109
+ await rejectNestedLinks(path);
110
+ return hashPath(path);
111
+ }
112
+ if (request.operation === "readFile") {
113
+ if (!(await lstat(path)).isFile()) throw new Error("Run-as readFile requires a regular file.");
114
+ return readFile2(path, "utf8");
115
+ }
116
+ return readdir2(path);
117
+ }
118
+ async function main() {
119
+ const [flagRoot, root, flagUid, rawUid, flagGid, rawGid] = process.argv.slice(2);
120
+ if (flagRoot !== "--root" || flagUid !== "--uid" || flagGid !== "--gid" || !root || !rawUid || !rawGid || process.argv.length !== 8) {
121
+ throw new Error("Invalid run-as worker arguments.");
122
+ }
123
+ const uid = Number(rawUid);
124
+ const gid = Number(rawGid);
125
+ if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid < 0 || process.getuid?.() !== uid || process.getgid?.() !== gid || (await lstat(root)).uid !== uid) {
126
+ throw new Error("Run-as worker UID or root ownership mismatch.");
127
+ }
128
+ const chunks = [];
129
+ let size = 0;
130
+ for await (const chunk of process.stdin) {
131
+ size += chunk.length;
132
+ if (size > 65536) throw new Error("Run-as request exceeds 64 KiB.");
133
+ chunks.push(chunk);
134
+ }
135
+ const request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
136
+ const result = await executeRead(root, request);
137
+ process.stdout.write(`${JSON.stringify({ version: 1, result })}
138
+ `);
139
+ }
140
+ if (process.argv[1] && /run-as-worker\.(?:js|mjs|ts)$/.test(process.argv[1])) {
141
+ main().catch((error) => {
142
+ process.stderr.write(`run-as worker: ${error instanceof Error ? error.message : String(error)}
143
+ `);
144
+ process.exitCode = 1;
145
+ });
146
+ }
147
+ export {
148
+ containedReadPath,
149
+ executeRead
150
+ };
package/openpack.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "name": "NestDevLab/agentwheel",
4
- "version": "0.20.19",
4
+ "version": "0.21.0",
5
5
  "provides": [
6
6
  { "type": "skills", "path": "skills" }
7
7
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentwheel",
3
- "version": "0.20.19",
3
+ "version": "0.21.0",
4
4
  "description": "Weave skills, rules, and instructions across every AI agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,7 @@ allowed-tools: [Bash]
5
5
  license: MIT
6
6
  metadata:
7
7
  author: NestDevLab
8
- version: "0.20.19"
8
+ version: "0.21.0"
9
9
  ---
10
10
 
11
11
  # agentwheel
@@ -357,6 +357,12 @@ Current config shape:
357
357
 
358
358
  Use named targets:
359
359
 
360
+ Configured packages may declare `"runtimes": ["claude"]` to select adapter runtimes. All
361
+ configured-package plans honor it, including `--agent`, `--profile`, status, and update. Named
362
+ targets match their declared adapter; plain workspace commands match each package's adapter.
363
+ Agents sharing an adapter cannot be selected separately. Upgrade all members to a version
364
+ supporting this field before adding it to shared configuration.
365
+
360
366
  ```bash
361
367
  agentwheel fleet register example-fleet --root /srv/agentwheel/fleets/example-fleet --required-package core-agent-pack
362
368
  agentwheel fleet list
@@ -475,6 +481,9 @@ Drift means a managed runtime output changed outside agentwheel. Fix drift by ch
475
481
  colliding upstream artifact.
476
482
  - Eject an item into `.agentwheel/ejected` when the user wants local ownership.
477
483
 
484
+ When the plan instead keeps a file as foreign or refuses because another workspace owns it, that is
485
+ an ownership problem, not drift: see "Choosing an ownership command" under Troubleshooting.
486
+
478
487
  Append durable local instruction text:
479
488
 
480
489
  ```bash
@@ -646,3 +655,23 @@ before Fleet qualification, owns paths under a fingerprint-only legacy key, run
646
655
  --from-workspace-root <previous-owner-root> --json` from that workspace. It moves only proven,
647
656
  still-desired entries into the stable state; add `--carry-drift` only after reviewing the drifted
648
657
  paths. Apply the emitted command, then require a normal install plan without force flags.
658
+
659
+ ### Choosing an ownership command
660
+
661
+ `agentwheel ownership --help` lists the commands; each subcommand's `--help` is the authoritative flag
662
+ reference. All of them dry-run by default and change only manifest metadata.
663
+
664
+ | Situation | Command |
665
+ |---|---|
666
+ | Unclear who owns what | `ownership recovery-plan` (read-only evidence report) |
667
+ | Refused: paths owned under a fingerprint-only legacy key | `ownership adopt-legacy` |
668
+ | `adopt-legacy` says entries are "already owned by the destination owner" (mixed legacy manifest) | Run a normal `install --agent <agent>` first; it migrates the state and keeps the foreign entry. Then hand off that entry. |
669
+ | Plan keeps one file as a `foreign artifact owned by …` in a stable manifest | `ownership handoff <type/name>` with `--from-workspace-root <root>`, or `--from-unknown-owner` for `workspace:unknown`; add `--to-fleet <id>` for a Fleet owner |
670
+ | Stale foreign entry already covered by the Fleet manifest | `ownership retire-stale` |
671
+
672
+ `handoff` refuses a file whose runtime hash differs from the manifest. If the drift is reviewed and
673
+ harmless, add `--carry-drift`; applying then also needs `--expected-runtime-hash` from the dry-run.
674
+ The recorded hash is kept, so the next install reports `DRIFT` for that file. Replacing it needs
675
+ `install --agent <agent> --force-drift`, which also force-refreshes every merge contribution of that
676
+ target (for example hooks): check that each merge destination is byte-identical afterwards. Never
677
+ narrow that install with `--select`: it removes unselected merge contributions.
@@ -5,7 +5,7 @@ allowed-tools: [Bash]
5
5
  license: MIT
6
6
  metadata:
7
7
  author: NestDevLab
8
- version: "0.20.19"
8
+ version: "0.21.0"
9
9
  ---
10
10
 
11
11
  # Agentwheel Discovery
@@ -1,17 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- applyCombinedInstallPlan,
4
- applyInstallPlan,
5
- commitManifestMetadataJournal,
6
- recoverPendingApply,
7
- uninstall
8
- } from "./chunk-L37KQVRS.js";
9
- import "./chunk-BHT57MK5.js";
10
- import "./chunk-BNIF3FUR.js";
11
- export {
12
- applyCombinedInstallPlan,
13
- applyInstallPlan,
14
- commitManifestMetadataJournal,
15
- recoverPendingApply,
16
- uninstall
17
- };