@jbarket/loomai 0.9.1 → 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/CHANGELOG.md +44 -1
- package/README.md +28 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/household.d.ts +3 -0
- package/dist/cli/household.js +139 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/serve.js +9 -2
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/config.js +5 -0
- package/dist/household.d.ts +101 -0
- package/dist/household.js +389 -0
- package/dist/injection/writer.js +5 -2
- package/dist/path-safety.d.ts +1 -1
- package/dist/path-safety.js +5 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +2 -2
- package/dist/tools/harness.d.ts +17 -0
- package/dist/tools/harness.js +45 -2
- package/dist/transport/http-server.d.ts +7 -0
- package/dist/transport/http-server.js +147 -20
- package/dist/ui/handler.d.ts +15 -2
- package/dist/ui/handler.js +26 -11
- package/package.json +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The household (docs/specs/household.md): one loom, many agents.
|
|
3
|
+
*
|
|
4
|
+
* $LOOM_HOME/household.json agents, the default agent, the owner
|
|
5
|
+
* $LOOM_HOME/tokens.json every token by its sha256: label, role, use
|
|
6
|
+
* $LOOM_HOME/agents/<name>/ an agent's context dir, exactly as before
|
|
7
|
+
*
|
|
8
|
+
* A context dir with no household around it is a household of one and
|
|
9
|
+
* behaves exactly as it always has: nothing here is consulted for it.
|
|
10
|
+
*
|
|
11
|
+
* Tokens carry a role: `agent:<name>` (MCP and the CLI as that agent),
|
|
12
|
+
* `owner` (everything, and the page), or `peer:<name>` (knowledge only).
|
|
13
|
+
* The daemon resolves the role on every request; no tool takes an agent
|
|
14
|
+
* argument, so a body can only ever be the agent its token names.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
|
+
export const AGENT_NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
21
|
+
const HOUSEHOLD_FILE = 'household.json';
|
|
22
|
+
const TOKENS_FILE = 'tokens.json';
|
|
23
|
+
/**
|
|
24
|
+
* When each token was last used, by sha256. Its own file so the daemon never
|
|
25
|
+
* rewrites tokens.json: a read-modify-write there could race a `loom token
|
|
26
|
+
* revoke` and quietly undo it.
|
|
27
|
+
*/
|
|
28
|
+
const USED_FILE = 'tokens.used.json';
|
|
29
|
+
export class HouseholdError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** $LOOM_HOME, else ~/.config/loom. */
|
|
32
|
+
export function householdHome(env = process.env) {
|
|
33
|
+
return resolve(env.LOOM_HOME || join(homedir(), '.config', 'loom'));
|
|
34
|
+
}
|
|
35
|
+
export function agentDir(h, agent) {
|
|
36
|
+
return join(h.home, 'agents', agent);
|
|
37
|
+
}
|
|
38
|
+
function readJson(path) {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
/** The household at `home`, or null when there's none. Throws on a malformed file. */
|
|
42
|
+
export function loadHousehold(home) {
|
|
43
|
+
const path = join(home, HOUSEHOLD_FILE);
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
return null;
|
|
46
|
+
const raw = readJson(path);
|
|
47
|
+
if (raw?.v !== 1)
|
|
48
|
+
throw new HouseholdError(`${path}: v must be 1`);
|
|
49
|
+
const agents = raw.agents;
|
|
50
|
+
if (!Array.isArray(agents) || agents.length === 0 || !agents.every((a) => typeof a === 'string' && AGENT_NAME.test(a))) {
|
|
51
|
+
throw new HouseholdError(`${path}: agents must be a non-empty list of agent names (lowercase letters, digits, dashes)`);
|
|
52
|
+
}
|
|
53
|
+
const def = typeof raw.default === 'string' ? raw.default : agents[0];
|
|
54
|
+
if (!agents.includes(def))
|
|
55
|
+
throw new HouseholdError(`${path}: default ${JSON.stringify(def)} is not one of the agents`);
|
|
56
|
+
return {
|
|
57
|
+
home: resolve(home),
|
|
58
|
+
agents: agents,
|
|
59
|
+
default: def,
|
|
60
|
+
owner: typeof raw.owner === 'string' ? raw.owner : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The household a context dir belongs to: one whose `agents/<name>` is this
|
|
65
|
+
* dir (after resolving symlinks, so an old `~/.config/loom/art` link still
|
|
66
|
+
* finds it). Null for a plain context dir.
|
|
67
|
+
*/
|
|
68
|
+
export function householdOf(contextDir) {
|
|
69
|
+
let real;
|
|
70
|
+
try {
|
|
71
|
+
real = realpathSync(contextDir);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const agentsDir = dirname(real);
|
|
77
|
+
if (basename(agentsDir) !== 'agents')
|
|
78
|
+
return null;
|
|
79
|
+
const h = loadHousehold(dirname(agentsDir));
|
|
80
|
+
const agent = basename(real);
|
|
81
|
+
if (!h || !h.agents.includes(agent))
|
|
82
|
+
return null;
|
|
83
|
+
return { household: h, agent };
|
|
84
|
+
}
|
|
85
|
+
export function parseRole(s) {
|
|
86
|
+
if (s === 'owner')
|
|
87
|
+
return { kind: 'owner' };
|
|
88
|
+
const m = /^(agent|peer):([a-z][a-z0-9-]{0,39})$/.exec(s);
|
|
89
|
+
if (!m)
|
|
90
|
+
return null;
|
|
91
|
+
return m[1] === 'agent' ? { kind: 'agent', agent: m[2] } : { kind: 'peer', name: m[2] };
|
|
92
|
+
}
|
|
93
|
+
export function roleString(r) {
|
|
94
|
+
return r.kind === 'owner' ? 'owner' : r.kind === 'agent' ? `agent:${r.agent}` : `peer:${r.name}`;
|
|
95
|
+
}
|
|
96
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
97
|
+
function writePrivate(path, body) {
|
|
98
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
99
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
100
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
101
|
+
chmodSync(tmp, 0o600);
|
|
102
|
+
renameSync(tmp, path);
|
|
103
|
+
}
|
|
104
|
+
function loadUsed(home) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = readJson(join(home, USED_FILE));
|
|
107
|
+
return Object.fromEntries(Object.entries(raw).filter((e) => typeof e[1] === 'string'));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Every token record, with when it was last used merged in. */
|
|
114
|
+
const LOCK_FILE = '.household.lock';
|
|
115
|
+
function alive(pid) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return err.code === 'EPERM';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Serialize every read-modify-write of household.json and tokens.json, so
|
|
126
|
+
* two `loom token` runs can't interleave and drop each other's change (a
|
|
127
|
+
* create racing a revoke must not bring the revoked token back). The lock
|
|
128
|
+
* holds its owner's pid. It is never broken automatically: every way of
|
|
129
|
+
* breaking a stale lock races another waiter doing the same, and these locks
|
|
130
|
+
* are only taken by the owner's own occasional commands, so a lock left by a
|
|
131
|
+
* dead process is reported with what to do instead.
|
|
132
|
+
*/
|
|
133
|
+
function withLock(home, f) {
|
|
134
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
135
|
+
const lock = join(home, LOCK_FILE);
|
|
136
|
+
const mine = String(process.pid);
|
|
137
|
+
const deadline = Date.now() + 5_000;
|
|
138
|
+
for (;;) {
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(lock, mine, { flag: 'wx', mode: 0o600 });
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err.code !== 'EEXIST')
|
|
145
|
+
throw err;
|
|
146
|
+
let holder = NaN;
|
|
147
|
+
try {
|
|
148
|
+
holder = Number.parseInt(readFileSync(lock, 'utf-8'), 10);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue; /* just released */
|
|
152
|
+
}
|
|
153
|
+
if (Number.isSafeInteger(holder) && holder > 0 && !alive(holder)) {
|
|
154
|
+
throw new HouseholdError(`${lock} was left by pid ${holder}, which isn't running; if no loom command is running, remove that file and try again`);
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() > deadline)
|
|
157
|
+
throw new HouseholdError(`${lock} is held by another loom process (pid ${Number.isNaN(holder) ? '?' : holder}); try again`);
|
|
158
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return f();
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
try {
|
|
166
|
+
if (readFileSync(lock, 'utf-8') === mine)
|
|
167
|
+
rmSync(lock, { force: true });
|
|
168
|
+
}
|
|
169
|
+
catch { /* already gone */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const SHA_HEX = /^[0-9a-f]{64}$/;
|
|
173
|
+
function validRecord(t) {
|
|
174
|
+
if (typeof t !== 'object' || t === null)
|
|
175
|
+
return false;
|
|
176
|
+
const r = t;
|
|
177
|
+
return typeof r.label === 'string' && typeof r.role === 'string' && typeof r.sha256 === 'string' && SHA_HEX.test(r.sha256)
|
|
178
|
+
&& typeof r.created === 'string' && (r.revoked === undefined || typeof r.revoked === 'string');
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The token file has to be private: owned by this user, not writable by
|
|
182
|
+
* group or world, in a directory that isn't either. Anyone who can write it
|
|
183
|
+
* can mint themselves an owner token.
|
|
184
|
+
*/
|
|
185
|
+
function assertPrivate(path) {
|
|
186
|
+
if (typeof process.getuid !== 'function')
|
|
187
|
+
return;
|
|
188
|
+
const uid = process.getuid();
|
|
189
|
+
for (const p of [path, dirname(path)]) {
|
|
190
|
+
const st = statSync(p);
|
|
191
|
+
if (st.uid !== uid)
|
|
192
|
+
throw new HouseholdError(`${p} is owned by another user; refusing to trust it`);
|
|
193
|
+
if (st.mode & 0o022)
|
|
194
|
+
throw new HouseholdError(`${p} is writable by group or others; run chmod go-w on it`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export function loadTokens(home) {
|
|
198
|
+
const path = join(home, TOKENS_FILE);
|
|
199
|
+
if (!existsSync(path))
|
|
200
|
+
return [];
|
|
201
|
+
assertPrivate(path);
|
|
202
|
+
const raw = readJson(path);
|
|
203
|
+
if (raw?.v !== 1 || !Array.isArray(raw.tokens))
|
|
204
|
+
throw new HouseholdError(`${path}: expected {"v": 1, "tokens": [...]}`);
|
|
205
|
+
if (!raw.tokens.every(validRecord))
|
|
206
|
+
throw new HouseholdError(`${path}: a token record is malformed (label, role, a 64-hex sha256 and created are required)`);
|
|
207
|
+
const used = loadUsed(home);
|
|
208
|
+
return raw.tokens.map((t) => {
|
|
209
|
+
const { last_used: _ignored, ...rest } = t;
|
|
210
|
+
return used[t.sha256] ? { ...rest, last_used: used[t.sha256] } : rest;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function saveTokens(home, tokens) {
|
|
214
|
+
const records = tokens.map(({ last_used: _l, ...t }) => t);
|
|
215
|
+
writePrivate(join(home, TOKENS_FILE), JSON.stringify({ v: 1, tokens: records }, null, 2) + '\n');
|
|
216
|
+
}
|
|
217
|
+
/** A role must name an agent this household has. */
|
|
218
|
+
function checkRole(h, role) {
|
|
219
|
+
if (role.kind === 'agent' && !h.agents.includes(role.agent)) {
|
|
220
|
+
throw new HouseholdError(`no agent named ${role.agent} in this household (${h.agents.join(', ')})`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Mint a token. Returns it once; only its sha256 is kept. */
|
|
224
|
+
export function createToken(h, input, now = new Date()) {
|
|
225
|
+
checkRole(h, input.role);
|
|
226
|
+
const label = input.label.trim();
|
|
227
|
+
if (!label || label.length > 120 || /[\x00-\x1f\x7f]/.test(label))
|
|
228
|
+
throw new HouseholdError('a token needs a label (120 printable characters at most)');
|
|
229
|
+
return withLock(h.home, () => {
|
|
230
|
+
const tokens = loadTokens(h.home);
|
|
231
|
+
if (tokens.some((t) => t.label === label && !t.revoked))
|
|
232
|
+
throw new HouseholdError(`a live token is already labeled ${JSON.stringify(label)}`);
|
|
233
|
+
const token = `loom_${randomBytes(32).toString('base64url')}`;
|
|
234
|
+
const record = { label, role: roleString(input.role), sha256: sha(token), created: now.toISOString() };
|
|
235
|
+
saveTokens(h.home, [...tokens, record]);
|
|
236
|
+
return { token, record };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
export function revokeToken(h, label, now = new Date()) {
|
|
240
|
+
return withLock(h.home, () => {
|
|
241
|
+
const tokens = loadTokens(h.home);
|
|
242
|
+
const t = tokens.find((x) => x.label === label && !x.revoked);
|
|
243
|
+
if (!t)
|
|
244
|
+
throw new HouseholdError(`no live token labeled ${JSON.stringify(label)}`);
|
|
245
|
+
t.revoked = now.toISOString();
|
|
246
|
+
saveTokens(h.home, tokens);
|
|
247
|
+
return t;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Resolves presented tokens to roles. Reads tokens.json when it changes (so
|
|
252
|
+
* a revoke takes effect on the next request), compares digests in constant
|
|
253
|
+
* time, and records when a token was last used, at most once a minute.
|
|
254
|
+
*/
|
|
255
|
+
export class TokenResolver {
|
|
256
|
+
h;
|
|
257
|
+
cache = null;
|
|
258
|
+
lastTouch = new Map();
|
|
259
|
+
constructor(h) {
|
|
260
|
+
this.h = h;
|
|
261
|
+
}
|
|
262
|
+
current() {
|
|
263
|
+
const path = join(this.h.home, TOKENS_FILE);
|
|
264
|
+
let key;
|
|
265
|
+
// Every write is a rename, so the inode changes even where mtimes are coarse.
|
|
266
|
+
try {
|
|
267
|
+
const st = statSync(path);
|
|
268
|
+
key = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.mode}`;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
if (!this.cache || this.cache.key !== key)
|
|
274
|
+
this.cache = { key, tokens: loadTokens(this.h.home) };
|
|
275
|
+
return this.cache.tokens;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The role for a presented token, or null. Revoked tokens and roles naming a
|
|
279
|
+
* missing agent resolve to null. Throws HouseholdError when tokens.json is
|
|
280
|
+
* unreadable or untrusted: the caller fails closed.
|
|
281
|
+
*/
|
|
282
|
+
resolve(presented) {
|
|
283
|
+
const d = Buffer.from(sha(presented), 'hex');
|
|
284
|
+
const hit = this.current().find((t) => !t.revoked && t.sha256.length === 64 && timingSafeEqual(Buffer.from(t.sha256, 'hex'), d));
|
|
285
|
+
if (!hit)
|
|
286
|
+
return null;
|
|
287
|
+
const role = parseRole(hit.role);
|
|
288
|
+
if (!role || (role.kind === 'agent' && !this.h.agents.includes(role.agent)))
|
|
289
|
+
return null;
|
|
290
|
+
this.touch(hit.sha256);
|
|
291
|
+
return role;
|
|
292
|
+
}
|
|
293
|
+
/** Whether a live owner token has this sha256 (the page's sessions check this, so revoking ends them). */
|
|
294
|
+
ownerDigestLive(digestHex) {
|
|
295
|
+
return this.current().some((t) => !t.revoked && t.role === 'owner' && t.sha256 === digestHex);
|
|
296
|
+
}
|
|
297
|
+
touch(digest) {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
if (now - (this.lastTouch.get(digest) ?? 0) < 60_000)
|
|
300
|
+
return;
|
|
301
|
+
this.lastTouch.set(digest, now);
|
|
302
|
+
// Only tokens.used.json, never tokens.json: see USED_FILE.
|
|
303
|
+
try {
|
|
304
|
+
const used = loadUsed(this.h.home);
|
|
305
|
+
used[digest] = new Date(now).toISOString();
|
|
306
|
+
writePrivate(join(this.h.home, USED_FILE), JSON.stringify(used) + '\n');
|
|
307
|
+
}
|
|
308
|
+
catch { /* last-used is a nicety; never fail a request over it */ }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Turn a plain context dir into the first agent of a new household: move
|
|
313
|
+
* `<home>/<name>` to `<home>/agents/<name>`, leave a symlink at the old path
|
|
314
|
+
* so anything that still names it keeps working, and write household.json.
|
|
315
|
+
*/
|
|
316
|
+
export function initHousehold(home, agent, opts = {}) {
|
|
317
|
+
if (!AGENT_NAME.test(agent))
|
|
318
|
+
throw new HouseholdError(`${agent} isn't an agent name (lowercase letters, digits, dashes)`);
|
|
319
|
+
home = resolve(home);
|
|
320
|
+
if (existsSync(join(home, HOUSEHOLD_FILE)))
|
|
321
|
+
throw new HouseholdError(`${home} already has a household.json`);
|
|
322
|
+
const from = join(home, agent);
|
|
323
|
+
const to = join(home, 'agents', agent);
|
|
324
|
+
if (existsSync(to))
|
|
325
|
+
throw new HouseholdError(`${to} already exists`);
|
|
326
|
+
if (existsSync(from) && lstatSync(from).isSymbolicLink()) {
|
|
327
|
+
throw new HouseholdError(`${from} is a symlink; move the real directory to ${to} yourself, or point LOOM_HOME at where it lives`);
|
|
328
|
+
}
|
|
329
|
+
if (!existsSync(join(from, 'IDENTITY.md')) && !existsSync(join(from, 'memories.db'))) {
|
|
330
|
+
throw new HouseholdError(`${from} doesn't look like a context dir (no IDENTITY.md or memories.db)`);
|
|
331
|
+
}
|
|
332
|
+
mkdirSync(join(home, 'agents'), { recursive: true, mode: 0o700 });
|
|
333
|
+
renameSync(from, to);
|
|
334
|
+
symlinkSync(join('agents', agent), from);
|
|
335
|
+
const h = { home, agents: [agent], default: agent, owner: opts.owner };
|
|
336
|
+
withLock(home, () => writePrivate(join(home, HOUSEHOLD_FILE), JSON.stringify({ v: 1, agents: h.agents, default: h.default, ...(h.owner ? { owner: h.owner } : {}) }, null, 2) + '\n'));
|
|
337
|
+
return h;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* An agent's directory must be a real directory, not a symlink: `agents/mark
|
|
341
|
+
* -> art` would make two agents share one stack.
|
|
342
|
+
*/
|
|
343
|
+
export function assertRealAgentDir(h, agent) {
|
|
344
|
+
const dir = agentDir(h, agent);
|
|
345
|
+
let st;
|
|
346
|
+
try {
|
|
347
|
+
st = lstatSync(dir);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
throw new HouseholdError(`${dir} doesn't exist; bootstrap the agent first`);
|
|
351
|
+
}
|
|
352
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
353
|
+
throw new HouseholdError(`${dir} must be a real directory, not a symlink or a file`);
|
|
354
|
+
}
|
|
355
|
+
/** Add an agent to a household (its directory must already exist under agents/). */
|
|
356
|
+
export function addAgent(h, agent) {
|
|
357
|
+
if (!AGENT_NAME.test(agent))
|
|
358
|
+
throw new HouseholdError(`${agent} isn't an agent name`);
|
|
359
|
+
if (h.agents.includes(agent))
|
|
360
|
+
return h;
|
|
361
|
+
assertRealAgentDir(h, agent);
|
|
362
|
+
return withLock(h.home, () => {
|
|
363
|
+
const current = loadHousehold(h.home) ?? h;
|
|
364
|
+
if (current.agents.includes(agent))
|
|
365
|
+
return current;
|
|
366
|
+
const next = { ...current, agents: [...current.agents, agent] };
|
|
367
|
+
writePrivate(join(h.home, HOUSEHOLD_FILE), JSON.stringify({ v: 1, agents: next.agents, default: next.default, ...(next.owner ? { owner: next.owner } : {}) }, null, 2) + '\n');
|
|
368
|
+
return next;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The context dir a household names when nothing else does: with LOOM_HOME
|
|
373
|
+
* set and a household.json there, `agents/<LOOM_AGENT or the default>`.
|
|
374
|
+
* Undefined otherwise. Only an explicit LOOM_HOME is consulted here, so a
|
|
375
|
+
* stray household.json never changes where an unconfigured process looks.
|
|
376
|
+
* (A daemon is different: one whose context dir already is
|
|
377
|
+
* `<home>/agents/<name>` serves that household; see householdOf.)
|
|
378
|
+
*/
|
|
379
|
+
export function contextFromHome(env = process.env) {
|
|
380
|
+
if (!env.LOOM_HOME)
|
|
381
|
+
return undefined;
|
|
382
|
+
const h = loadHousehold(householdHome(env));
|
|
383
|
+
if (!h)
|
|
384
|
+
return undefined;
|
|
385
|
+
const agent = env.LOOM_AGENT || h.default;
|
|
386
|
+
if (!h.agents.includes(agent))
|
|
387
|
+
throw new HouseholdError(`LOOM_AGENT=${agent} isn't in the household at ${h.home} (${h.agents.join(', ')})`);
|
|
388
|
+
return agentDir(h, agent);
|
|
389
|
+
}
|
package/dist/injection/writer.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, writeFile, rename, mkdir, stat, chmod } from 'node:fs/promises';
|
|
14
14
|
import { dirname } from 'node:path';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
15
16
|
export class MalformedMarkersError extends Error {
|
|
16
17
|
path;
|
|
17
18
|
reason;
|
|
@@ -66,9 +67,11 @@ async function readIfExists(path) {
|
|
|
66
67
|
}
|
|
67
68
|
export async function atomicWrite(path, content, preserveModeFrom) {
|
|
68
69
|
await mkdir(dirname(path), { recursive: true });
|
|
69
|
-
|
|
70
|
+
// A fresh, random temp name opened exclusively ('wx'): a fixed name could be
|
|
71
|
+
// a symlink someone left there, and writing through it would land anywhere.
|
|
72
|
+
const tmp = `${path}.${randomBytes(6).toString('hex')}.loom.tmp`;
|
|
70
73
|
const buf = Buffer.from(content, 'utf-8');
|
|
71
|
-
await writeFile(tmp, buf);
|
|
74
|
+
await writeFile(tmp, buf, { flag: 'wx' });
|
|
72
75
|
if (preserveModeFrom) {
|
|
73
76
|
try {
|
|
74
77
|
const s = await stat(preserveModeFrom);
|
package/dist/path-safety.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns a human-readable error string when `value` cannot be used as a
|
|
3
3
|
* single path segment, or null when it is safe. Rejects empty strings,
|
|
4
|
-
* '.', '..', path separators (both / and \), and
|
|
4
|
+
* '.', '..', path separators (both / and \), and control characters.
|
|
5
5
|
*/
|
|
6
6
|
export declare function pathSegmentError(value: string, paramName: string): string | null;
|
|
7
7
|
/**
|
package/dist/path-safety.js
CHANGED
|
@@ -18,7 +18,7 @@ import { atomicWrite } from './injection/writer.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* Returns a human-readable error string when `value` cannot be used as a
|
|
20
20
|
* single path segment, or null when it is safe. Rejects empty strings,
|
|
21
|
-
* '.', '..', path separators (both / and \), and
|
|
21
|
+
* '.', '..', path separators (both / and \), and control characters.
|
|
22
22
|
*/
|
|
23
23
|
export function pathSegmentError(value, paramName) {
|
|
24
24
|
if (value === '' ||
|
|
@@ -26,9 +26,11 @@ export function pathSegmentError(value, paramName) {
|
|
|
26
26
|
value === '..' ||
|
|
27
27
|
value.includes('/') ||
|
|
28
28
|
value.includes('\\') ||
|
|
29
|
-
|
|
29
|
+
// Control characters (NUL, newlines, …): a name that ends up inside a
|
|
30
|
+
// file must not be able to start a new line there.
|
|
31
|
+
/[\x00-\x1f\x7f]/.test(value)) {
|
|
30
32
|
return (`Invalid ${paramName} ${JSON.stringify(value)}: ` +
|
|
31
|
-
'must be a non-empty name with no path separators.');
|
|
33
|
+
'must be a non-empty name with no path separators or control characters.');
|
|
32
34
|
}
|
|
33
35
|
return null;
|
|
34
36
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface LoomServerConfig {
|
|
|
8
8
|
* signal channel. Set via LOOM_GAPS_DIR env var in production.
|
|
9
9
|
*/
|
|
10
10
|
gapsDir?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Served over the network (the HTTP daemon), not as a local stdio server.
|
|
13
|
+
* Tools that would touch the server's own filesystem outside the stack
|
|
14
|
+
* (harness_init's `target`) refuse then: the path would name a file on the
|
|
15
|
+
* daemon's machine, not the caller's.
|
|
16
|
+
*/
|
|
17
|
+
remote?: boolean;
|
|
11
18
|
}
|
|
12
19
|
export interface LoomServerInstance {
|
|
13
20
|
server: McpServer;
|
package/dist/server.js
CHANGED
|
@@ -38,7 +38,7 @@ import { memoryRevisionRestore } from './tools/memory-revision-restore.js';
|
|
|
38
38
|
import { personWrite, personVerify, personArchive, personRestore, person, personList, personHistory, personRevisionRestore, } from './tools/person.js';
|
|
39
39
|
// ─── Server Factory ───────────────────────────────────────────────────────────
|
|
40
40
|
export function createLoomServer(config) {
|
|
41
|
-
const { contextDir, gapsDir } = config;
|
|
41
|
+
const { contextDir, gapsDir, remote = false } = config;
|
|
42
42
|
// Refuse to boot against a stack this loom build doesn't understand.
|
|
43
43
|
assertStackVersionCompatible(contextDir);
|
|
44
44
|
// Refuse to serve a blank identity from the default fallback path.
|
|
@@ -474,7 +474,7 @@ export function createLoomServer(config) {
|
|
|
474
474
|
},
|
|
475
475
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
476
476
|
}, async ({ name, overwrite, target }) => {
|
|
477
|
-
const text = await harnessInit(contextDir, { name, overwrite, target });
|
|
477
|
+
const text = await harnessInit(contextDir, { name, overwrite, target }, { remote });
|
|
478
478
|
return { content: [{ type: 'text', text }] };
|
|
479
479
|
});
|
|
480
480
|
server.registerTool('harness_describe', {
|
package/dist/tools/harness.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why `target` may not be written, or null. Over the network it never may:
|
|
3
|
+
* the path is on the daemon's machine, and a caller who could name it could
|
|
4
|
+
* write into another agent's stack or a shell rc file. Locally it must be an
|
|
5
|
+
* instruction file a harness reads, by name.
|
|
6
|
+
*/
|
|
7
|
+
export declare function harnessTargetError(target: string, opts: {
|
|
8
|
+
remote: boolean;
|
|
9
|
+
}, real?: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Where a target really is: through a symlink (a dotfiles repo's CLAUDE.md),
|
|
12
|
+
* so the rule above applies to the file actually written, and the write goes
|
|
13
|
+
* there instead of replacing the link.
|
|
14
|
+
*/
|
|
15
|
+
export declare function realTarget(target: string): string;
|
|
1
16
|
export declare function harnessInit(contextDir: string, input: {
|
|
2
17
|
name: string;
|
|
3
18
|
overwrite?: boolean;
|
|
4
19
|
target?: string;
|
|
20
|
+
}, opts?: {
|
|
21
|
+
remote?: boolean;
|
|
5
22
|
}): Promise<string>;
|
|
6
23
|
/**
|
|
7
24
|
* MCP tool handler for harness self-description.
|
package/dist/tools/harness.js
CHANGED
|
@@ -8,11 +8,54 @@
|
|
|
8
8
|
* "no-change" when the block is already present and intact, "updated"
|
|
9
9
|
* when it was missing or corrupted.
|
|
10
10
|
*/
|
|
11
|
+
import { realpathSync } from 'node:fs';
|
|
11
12
|
import { initHarness, describeHarness, resolvePeerToHarness, normalizePeer } from '../blocks/harness.js';
|
|
12
13
|
import { renderBlock } from '../injection/render.js';
|
|
13
14
|
import { writeManagedBlock } from '../injection/writer.js';
|
|
14
15
|
import { HARNESSES, isHarnessKey } from '../injection/harnesses.js';
|
|
15
|
-
|
|
16
|
+
/** The instruction files a managed block may be written into: what harnesses read. */
|
|
17
|
+
const INSTRUCTION_FILES = new Set(['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'CODEX.md', 'QWEN.md', 'CLAUDE.local.md']);
|
|
18
|
+
/**
|
|
19
|
+
* Why `target` may not be written, or null. Over the network it never may:
|
|
20
|
+
* the path is on the daemon's machine, and a caller who could name it could
|
|
21
|
+
* write into another agent's stack or a shell rc file. Locally it must be an
|
|
22
|
+
* instruction file a harness reads, by name.
|
|
23
|
+
*/
|
|
24
|
+
export function harnessTargetError(target, opts, real = realTarget(target)) {
|
|
25
|
+
if (opts.remote) {
|
|
26
|
+
return 'Error: harness_init can\'t write a target file over the network: the path would be on the loom server, not your machine. Run `loom inject --harness <name> --to <file>` on your own machine instead.';
|
|
27
|
+
}
|
|
28
|
+
if (/[\x00-\x1f\x7f]/.test(target))
|
|
29
|
+
return 'Error: target contains control characters.';
|
|
30
|
+
for (const p of [target, real]) {
|
|
31
|
+
const base = p.split(/[\\/]/).pop() ?? '';
|
|
32
|
+
if (!INSTRUCTION_FILES.has(base)) {
|
|
33
|
+
return `Error: target must be a harness instruction file (${[...INSTRUCTION_FILES].join(', ')}), not ${JSON.stringify(base)}.`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where a target really is: through a symlink (a dotfiles repo's CLAUDE.md),
|
|
40
|
+
* so the rule above applies to the file actually written, and the write goes
|
|
41
|
+
* there instead of replacing the link.
|
|
42
|
+
*/
|
|
43
|
+
export function realTarget(target) {
|
|
44
|
+
try {
|
|
45
|
+
return realpathSync(target);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function harnessInit(contextDir, input, opts = {}) {
|
|
52
|
+
// Resolve the target once; the path checked is the path written.
|
|
53
|
+
const real = input.target !== undefined ? realTarget(input.target) : undefined;
|
|
54
|
+
if (input.target !== undefined) {
|
|
55
|
+
const err = harnessTargetError(input.target, { remote: opts.remote ?? false }, real);
|
|
56
|
+
if (err)
|
|
57
|
+
return err;
|
|
58
|
+
}
|
|
16
59
|
const manifestResult = await initHarness(contextDir, input.name, {
|
|
17
60
|
overwrite: input.overwrite,
|
|
18
61
|
});
|
|
@@ -22,7 +65,7 @@ export async function harnessInit(contextDir, input) {
|
|
|
22
65
|
? HARNESSES[input.name]
|
|
23
66
|
: { key: input.name, display: input.name, defaultPath: input.target, toolPrefix: 'mcp__loom__' };
|
|
24
67
|
const block = renderBlock(preset, contextDir);
|
|
25
|
-
const blockResult = await writeManagedBlock(
|
|
68
|
+
const blockResult = await writeManagedBlock(real, block);
|
|
26
69
|
lines.push(`Managed block: ${blockResult.path} (${blockResult.action})`);
|
|
27
70
|
}
|
|
28
71
|
return lines.join('\n');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { type Household } from '../household.js';
|
|
2
3
|
export interface HttpServeOptions {
|
|
3
4
|
contextDir: string;
|
|
4
5
|
host: string;
|
|
@@ -34,6 +35,12 @@ export interface HttpServeOptions {
|
|
|
34
35
|
}) => {
|
|
35
36
|
server: McpServer;
|
|
36
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Household mode: tokens resolve to roles from household.home/tokens.json
|
|
40
|
+
* (plus `token`, if set, as an owner token). Mutually exclusive with
|
|
41
|
+
* `tokens` and `createServer`; `contextDir` should be the default agent's.
|
|
42
|
+
*/
|
|
43
|
+
household?: Household;
|
|
37
44
|
}
|
|
38
45
|
export interface HttpServeHandle {
|
|
39
46
|
/** The actually-bound port (resolves a 0 request to the OS-assigned port). */
|