@kevin5251984/guild 0.2.19 → 0.2.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevin5251984/guild",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "dev": "tsx src/cli.ts",
33
33
  "start": "node ./bin/guildd.mjs",
34
- "build": "tsc -p tsconfig.json",
34
+ "build": "node --check ./bin/guildd.mjs",
35
35
  "test": "tsx --test test/*.test.ts",
36
36
  "prepack": "node ./scripts/vendor-protocol.mjs prepack",
37
37
  "postpack": "node ./scripts/vendor-protocol.mjs postpack"
package/src/cli.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { execFile } from "node:child_process";
3
2
  import { startGuildDaemon } from "./start.ts";
4
3
  import {
@@ -6,6 +5,7 @@ import {
6
5
  parseGuildCli,
7
6
  shouldOpenBrowser,
8
7
  } from "./cli-args.ts";
8
+ import { guildVersion } from "./version.ts";
9
9
 
10
10
  const opts = parseGuildCli(process.argv);
11
11
  if (opts.error) {
@@ -15,10 +15,7 @@ if (opts.error) {
15
15
  } else if (opts.help) {
16
16
  process.stdout.write(guildCliHelp());
17
17
  } else if (opts.version) {
18
- const pkg = JSON.parse(
19
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
20
- ) as { version: string };
21
- process.stdout.write(`${pkg.version}\n`);
18
+ process.stdout.write(`${guildVersion()}\n`);
22
19
  } else {
23
20
  if (opts.port !== undefined) process.env.GUILD_PORT = String(opts.port);
24
21
  const started = startGuildDaemon();
package/src/db.ts CHANGED
@@ -25,7 +25,14 @@ import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
25
25
  export const GUILD_DB_FILE = "guild.sqlite";
26
26
  /** Per-room hot window in SQLite. Older rows spill to rooms/<id>/trajectory.jsonl. */
27
27
  export const TRAJECTORY_HOT_CAP = 1000;
28
- const SCHEMA_VERSION = "2";
28
+ /** Bump when this guildd writes a shape an older guildd cannot read. */
29
+ export const SCHEMA_VERSION = "2";
30
+
31
+ /** Numeric view of a `meta.schema` value. Missing/garbage = 0 (migrate). */
32
+ function schemaVersionOf(raw: unknown): number {
33
+ const value = Number(typeof raw === "string" ? raw.trim() : raw);
34
+ return Number.isFinite(value) ? value : 0;
35
+ }
29
36
  const WAREHOUSE_TAIL = 1024 * 1024;
30
37
 
31
38
  const SCHEMA = `
@@ -281,7 +288,24 @@ export class GuildDb {
281
288
  constructor(readonly path: string) {
282
289
  mkdirSync(dirname(path), { recursive: true });
283
290
  this.sqlite = new DatabaseSync(path, { timeout: 5000 });
284
- this.sqlite.exec(SCHEMA);
291
+ try {
292
+ this.sqlite.exec(SCHEMA);
293
+ // Refuse to open a DB written by a newer guildd: the ALTERs below and the
294
+ // version upsert would silently migrate the file back down.
295
+ const row = this.sqlite
296
+ .prepare("SELECT value FROM meta WHERE key = 'schema'")
297
+ .get() as { value?: unknown } | undefined;
298
+ const stored = schemaVersionOf(row?.value);
299
+ const current = schemaVersionOf(SCHEMA_VERSION);
300
+ if (stored > current) {
301
+ throw new Error(
302
+ `guild.sqlite schema ${stored} is newer than this guildd (${current}); upgrade guildd`,
303
+ );
304
+ }
305
+ } catch (error) {
306
+ this.close();
307
+ throw error;
308
+ }
285
309
  try {
286
310
  this.sqlite.exec("ALTER TABLE messages ADD COLUMN steer_bot_id TEXT");
287
311
  } catch {
@@ -302,13 +326,22 @@ export class GuildDb {
302
326
  } catch {
303
327
  /* column already exists on fresh schema */
304
328
  }
305
- this.sqlite.prepare(
306
- "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
307
- ).run(SCHEMA_VERSION);
329
+ try {
330
+ this.sqlite.prepare(
331
+ "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
332
+ ).run(SCHEMA_VERSION);
333
+ } catch (error) {
334
+ this.close();
335
+ throw error;
336
+ }
308
337
  }
309
338
 
310
339
  close(): void {
311
- this.sqlite.close();
340
+ try {
341
+ this.sqlite.close();
342
+ } catch {
343
+ /* already closed */
344
+ }
312
345
  }
313
346
 
314
347
  importLegacyFiles(dataDir: string): void {
package/src/generate.ts CHANGED
@@ -350,9 +350,9 @@ When work belongs to someone else, put @handle at the start of a line with a wri
350
350
  - Done when
351
351
  - Constraints / out of scope
352
352
  - Files or evidence
353
- Each line-start @handle on this quest starts that seat. A markdown numbered list that names a teammate (1. @design) also starts them, even if the handle is wrapped in backticks. Mentions that are only commentary in a sentence do not dispatch.
354
- Do not @all unless the human did. Do not recruit extra people; the human staffs the roster (max ${CHANNEL_ROSTER_CAP} on a quest).
355
- You may @handle any staffed teammate whose job is the next step, even if the human only named you this turn. That is how the hall continues. Do not dump the same work on every seat. If two seats must run in order, only @ the seat that can start now a numbered list that names later seats starts them this turn too. Do not write a plan and stop.
353
+ Each line-start @handle of a bot already on this quest starts that seat this turn. A markdown numbered list item that leads with a teammate (1. @design) also starts them, even if the handle is wrapped in backticks. Mentions that are only commentary in a sentence do not dispatch.
354
+ Do not @all unless the human did. Do not recruit extra people; the human staffs the roster with 加入 (max ${CHANNEL_ROSTER_CAP} on a quest). @handle never adds a seat.
355
+ You may @handle any staffed teammate whose job is the next step, even if the human only named you this turn. That is how the hall continues. Do not @handle a bot who is not on this quest. Do not dump the same work on every seat. If two seats must run in order, only @ the seat that can start now. Later seats stay in prose (四席完成後由 @infra, 通過後 @marketing, 最後 @infra) — those do not start this turn. After the first wave reports, @handle the next seat with a spec. Do not write a plan and stop.
356
356
  Stay quiet: no status theater, no "I'll start now." Speak when you finish, block, or need a decision. Money, sends, and destructive actions wait for the human.
357
357
 
358
358
  Harness this turn (Memory → Plan → Skills → Act):
package/src/handlers.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  listGuildMcp,
44
44
  listHostMcp,
45
45
  listMcpToolRefs,
46
+ publicMcpServer,
46
47
  removeGuildMcp,
47
48
  upsertGuildMcp,
48
49
  } from "./mcp.ts";
@@ -92,12 +93,13 @@ export function listLibrary(store: GuildStore, kind: LibraryKind) {
92
93
  return store.listLibrary(kind);
93
94
  }
94
95
 
96
+ /** HTTP surfaces never carry `launch.env` values; see `publicMcpServer`. */
95
97
  export function listMcpServers(store: GuildStore) {
96
- return listGuildMcp(store.dataDir);
98
+ return listGuildMcp(store.dataDir).map(publicMcpServer);
97
99
  }
98
100
 
99
101
  export function listHostMcpServers() {
100
- return listHostMcp();
102
+ return listHostMcp().map(publicMcpServer);
101
103
  }
102
104
 
103
105
  export function createMcpServer(
@@ -112,13 +114,15 @@ export function createMcpServer(
112
114
  },
113
115
  ) {
114
116
  try {
115
- return upsertGuildMcp(store.dataDir, input.name, {
116
- command: input.command || "",
117
- args: input.args || [],
118
- env: input.env,
119
- cwd: input.cwd,
120
- url: input.url,
121
- });
117
+ return publicMcpServer(
118
+ upsertGuildMcp(store.dataDir, input.name, {
119
+ command: input.command || "",
120
+ args: input.args || [],
121
+ env: input.env,
122
+ cwd: input.cwd,
123
+ url: input.url,
124
+ }),
125
+ );
122
126
  } catch (error) {
123
127
  throw new StoreError(
124
128
  400,
@@ -129,7 +133,7 @@ export function createMcpServer(
129
133
 
130
134
  export function importMcpServer(store: GuildStore, hostId: string) {
131
135
  try {
132
- return importHostMcp(store.dataDir, hostId);
136
+ return publicMcpServer(importHostMcp(store.dataDir, hostId));
133
137
  } catch (error) {
134
138
  throw new StoreError(
135
139
  404,
@@ -753,32 +757,14 @@ function askedText(
753
757
  return `附件:\n${legend}\n\n${asked}`;
754
758
  }
755
759
 
756
- /** @handle of a bot not in this channel adds them, then they can reply. */
760
+ /** Roster is staffed in the members panel. @handle never pulls a new seat. */
757
761
  export function inviteMentionedBots(
758
762
  store: GuildStore,
759
763
  roomId: string,
760
- userMessage: { author?: string; body: string; mentions?: string[] },
764
+ _userMessage?: { author?: string; body: string; mentions?: string[] },
761
765
  ): string[] {
762
766
  const room = store.getRoom(roomId);
763
- if (!room) return [];
764
- if (room.kind !== "channel") return room.memberIds;
765
- if (isBroadcastMention(userMessage.body)) return room.memberIds;
766
- const ids = messageMentionIds(
767
- {
768
- author: userMessage.author || "you",
769
- body: userMessage.body,
770
- mentions: userMessage.mentions,
771
- },
772
- store.listBots(),
773
- );
774
- let memberIds = room.memberIds;
775
- for (const id of ids) {
776
- if (memberIds.includes(id)) continue;
777
- if (!store.getBot(id)) continue;
778
- store.addMember(roomId, id);
779
- memberIds = [...memberIds, id];
780
- }
781
- return memberIds;
767
+ return room?.memberIds ?? [];
782
768
  }
783
769
 
784
770
  export function channelMarkdownForRoom(
@@ -1180,7 +1166,7 @@ async function generateReplies(
1180
1166
  turnAsked: string,
1181
1167
  turnHistory: HistoryItem[],
1182
1168
  ) => {
1183
- if (!memberIds.includes(botId) && !onlyBotId) return;
1169
+ if (!memberIds.includes(botId)) return;
1184
1170
  if (signal.aborted) return;
1185
1171
  const prev = store.getLiveBotTurn(roomId, botId);
1186
1172
  const startedAt = prev?.startedAt || new Date().toISOString();
@@ -1418,7 +1404,7 @@ function plantLiveTurns(
1418
1404
  const startedAt = new Date().toISOString();
1419
1405
  for (const botId of botIds) {
1420
1406
  if (!botId) continue;
1421
- if (!memberIds.includes(botId) && !onlyBotId) continue;
1407
+ if (!memberIds.includes(botId)) continue;
1422
1408
  store.dropLastFailedReply(roomId, botId);
1423
1409
  store.setLiveTurn(roomId, {
1424
1410
  botId,
@@ -1482,18 +1468,13 @@ function hasExplicitSummon(
1482
1468
  }
1483
1469
 
1484
1470
  function includeFollowBot(
1485
- store: GuildStore,
1486
- roomId: string,
1471
+ _store: GuildStore,
1472
+ _roomId: string,
1487
1473
  memberIds: string[],
1488
1474
  follow?: string,
1489
1475
  ): string[] {
1490
1476
  if (!follow || memberIds.includes(follow)) return memberIds;
1491
- try {
1492
- store.addMember(roomId, follow);
1493
- return [...memberIds, follow];
1494
- } catch {
1495
- return memberIds;
1496
- }
1477
+ return memberIds;
1497
1478
  }
1498
1479
 
1499
1480
  function inviteAssignee(
@@ -1504,12 +1485,10 @@ function inviteAssignee(
1504
1485
  const room = store.getRoom(roomId);
1505
1486
  if (!room) return [];
1506
1487
  if (room.kind !== "channel") return room.memberIds;
1507
- if (room.memberIds.includes(assigneeId)) return room.memberIds;
1508
1488
  if (!store.getBot(assigneeId)) {
1509
1489
  throw new StoreError(400, "assignee does not exist");
1510
1490
  }
1511
- store.addMember(roomId, assigneeId);
1512
- return [...room.memberIds, assigneeId];
1491
+ return room.memberIds;
1513
1492
  }
1514
1493
 
1515
1494
  export async function postUserMessage(
@@ -1730,4 +1709,9 @@ export async function retryMessage(
1730
1709
 
1731
1710
  export { StoreError, localGenerate };
1732
1711
 
1733
- export { publicModels, mergeModelsFile, refreshOpenCodeFreeCatalog } from "./llm.ts";
1712
+ export {
1713
+ publicModels,
1714
+ mergeModelsFile,
1715
+ refreshOpenCodeFreeCatalog,
1716
+ refreshReasoningCatalog,
1717
+ } from "./llm.ts";
package/src/harness.ts CHANGED
@@ -171,9 +171,10 @@ export function gateTool(
171
171
  };
172
172
  }
173
173
 
174
+ // No explicit workspace means the guild checkout, never all of $HOME.
174
175
  const workspace = input.workspace?.trim()
175
176
  ? resolveToolPath(input.workspace)
176
- : HOME;
177
+ : defaultWorkspace();
177
178
 
178
179
  if (name.startsWith("mcp__")) {
179
180
  return {
@@ -182,9 +183,20 @@ export function gateTool(
182
183
  };
183
184
  }
184
185
 
186
+ if (name === "read" || name === "list") {
187
+ const raw = typeof args.path === "string" ? args.path : "";
188
+ if (name === "list" && !raw.trim()) return null;
189
+ const target = resolveToolPath(raw, workspace);
190
+ if (!pathInsideWorkspace(target, workspace)) {
191
+ return {
192
+ text: `sandbox=workspace_write refused ${name} outside workspace: ${target}`,
193
+ isError: true,
194
+ };
195
+ }
196
+ return null;
197
+ }
198
+
185
199
  if (
186
- name === "read" ||
187
- name === "list" ||
188
200
  name === "skill" ||
189
201
  name === "spawn" ||
190
202
  name === "read_spawn"
@@ -224,7 +236,11 @@ export function gateTool(
224
236
  };
225
237
  }
226
238
 
227
- return null;
239
+ // browser (CDP into the user's Chrome profile) and anything unnamed: refuse.
240
+ return {
241
+ text: `sandbox=workspace_write refused ${name}; use full_access`,
242
+ isError: true,
243
+ };
228
244
  }
229
245
 
230
246
  export type LoopCall = {
@@ -3,10 +3,11 @@ import {
3
3
  existsSync,
4
4
  readdirSync,
5
5
  readFileSync,
6
+ realpathSync,
6
7
  statSync,
7
8
  } from "node:fs";
8
9
  import { homedir } from "node:os";
9
- import { dirname, join, resolve } from "node:path";
10
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
10
11
  import { promisify } from "node:util";
11
12
  import { StoreError } from "./store.ts";
12
13
 
@@ -31,6 +32,117 @@ function resolveUserPath(input: string): string {
31
32
  return resolve(HOME, trimmed);
32
33
  }
33
34
 
35
+ /** Guild data dirs: `~/.guild` plus an explicit `GUILD_HOME` if set. */
36
+ function guildHomes(): string[] {
37
+ const homes = [join(HOME, ".guild")];
38
+ const extra = process.env.GUILD_HOME?.trim();
39
+ if (extra) homes.push(resolveUserPath(extra));
40
+ return homes;
41
+ }
42
+
43
+ const SSH_DIR = join(HOME, ".ssh");
44
+ /** Files whose *contents* are credentials, wherever the guild home lives. */
45
+ const SECRET_GUILD_FILES = new Set(["oauth.json", "models.json", "mcp.json"]);
46
+ /** Credential dotfiles that live directly in `$HOME`. */
47
+ const SECRET_HOME_FILES = new Set([
48
+ ".claude.json",
49
+ ".netrc",
50
+ "_netrc",
51
+ ".npmrc",
52
+ ".yarnrc.yml",
53
+ ".git-credentials",
54
+ ".env",
55
+ ".env.local",
56
+ ".env.production",
57
+ ".pgpass",
58
+ ".pypirc",
59
+ ".my.cnf",
60
+ "credentials.json",
61
+ ]);
62
+ /** `$HOME` folders that are credential stores: the folder and everything below. */
63
+ const SECRET_HOME_DIRS = [
64
+ ".aws",
65
+ ".docker",
66
+ ".gnupg",
67
+ ".claude",
68
+ ".codex",
69
+ ".cursor",
70
+ ".kube",
71
+ ".azure",
72
+ join(".config", "gcloud"),
73
+ join(".config", "gh"),
74
+ join("Library", "Keychains"),
75
+ ].map((relative) => join(HOME, relative));
76
+ const SSH_KEY_NAME = /^(id_rsa|id_dsa|id_ecdsa|id_ed25519|id_xmss)$/i;
77
+ const SECRET_SUFFIXES = [
78
+ ".pem",
79
+ ".p12",
80
+ ".pfx",
81
+ ".key",
82
+ ".p8",
83
+ ".jks",
84
+ ".keystore",
85
+ ];
86
+
87
+ function under(path: string, dir: string): boolean {
88
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
89
+ return path === dir || path.startsWith(prefix);
90
+ }
91
+
92
+ /** Longest existing ancestor resolved through symlinks, tail re-joined. */
93
+ function canonicalPath(target: string): string {
94
+ let abs = resolve(target);
95
+ const tail: string[] = [];
96
+ for (;;) {
97
+ try {
98
+ const real = realpathSync(abs);
99
+ return tail.length ? resolve(real, ...tail) : real;
100
+ } catch {
101
+ const parent = dirname(abs);
102
+ if (parent === abs) return tail.length ? resolve(abs, ...tail) : abs;
103
+ tail.unshift(basename(abs));
104
+ abs = parent;
105
+ }
106
+ }
107
+ }
108
+
109
+ function isSecretPath(abs: string): boolean {
110
+ const name = basename(abs);
111
+ const lower = name.toLowerCase();
112
+ const publicKey = lower.endsWith(".pub");
113
+ for (const home of guildHomes()) {
114
+ if (under(abs, join(home, "browser-profile"))) return true;
115
+ if (under(abs, home) && SECRET_GUILD_FILES.has(lower)) return true;
116
+ }
117
+ if (dirname(abs) === HOME && SECRET_HOME_FILES.has(lower)) return true;
118
+ if (name === ".env" || name.startsWith(".env.")) return true;
119
+ if (lower === "credentials.json" || lower === "service-account.json") {
120
+ return true;
121
+ }
122
+ if (SECRET_HOME_DIRS.some((dir) => under(abs, dir))) return true;
123
+ if (under(abs, SSH_DIR) && abs !== SSH_DIR && !publicKey) return true;
124
+ if (SSH_KEY_NAME.test(name)) return true;
125
+ if (!publicKey && SECRET_SUFFIXES.some((suffix) => lower.endsWith(suffix))) {
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * The attach picker browses `$HOME` on purpose, so /host/* is not confined to a
133
+ * workspace. Secrets still have to stay shut: OAuth tokens, model keys, the MCP
134
+ * store, the cloned browser profile, private keys, and the usual credential
135
+ * dotfiles / folders (`.aws`, `.claude`, `.codex`, `.npmrc`, `.netrc`, …).
136
+ * This is a denylist over the picker, not a chroot: non-secret `$HOME` and
137
+ * system files such as `/etc/passwd` stay readable.
138
+ */
139
+ export function assertHostPathAllowed(target: string): void {
140
+ const abs = isAbsolute(target) ? resolve(target) : resolveUserPath(target);
141
+ if (isSecretPath(canonicalPath(abs))) {
142
+ throw new StoreError(403, "host path refused");
143
+ }
144
+ }
145
+
34
146
  function parentOf(path: string): string | null {
35
147
  const parent = dirname(path);
36
148
  if (parent === path) return null;
@@ -52,6 +164,7 @@ export function hostList(rawPath: string): {
52
164
  } {
53
165
  try {
54
166
  const target = resolveUserPath(rawPath);
167
+ assertHostPathAllowed(target);
55
168
  const st = statSync(target);
56
169
  if (!st.isDirectory()) throw new StoreError(400, "not a directory");
57
170
  const entries = readdirSync(target, { withFileTypes: true })
@@ -94,6 +207,7 @@ export function hostRead(rawPath: string): {
94
207
  } {
95
208
  try {
96
209
  const target = resolveUserPath(rawPath);
210
+ assertHostPathAllowed(target);
97
211
  const st = statSync(target);
98
212
  if (!st.isFile()) throw new StoreError(400, "not a file");
99
213
  const raw = readFileSync(target);
@@ -146,6 +260,7 @@ function walkTree(
146
260
  export function hostTree(rawPath: string): { path: string; text: string } {
147
261
  try {
148
262
  const target = resolveUserPath(rawPath);
263
+ assertHostPathAllowed(target);
149
264
  const st = statSync(target);
150
265
  if (!st.isDirectory()) throw new StoreError(400, "not a directory");
151
266
  const lines = [target];
@@ -158,6 +273,25 @@ export function hostTree(rawPath: string): { path: string; text: string } {
158
273
  }
159
274
  }
160
275
 
276
+ /**
277
+ * Git runs inside the user's tree: ignore their global/system config (hooks,
278
+ * fsmonitor, credential helpers) and never prompt on a TTY-less daemon.
279
+ */
280
+ const GIT_GUARD = [
281
+ "-c",
282
+ "core.fsmonitor=false",
283
+ "-c",
284
+ "core.untrackedCache=false",
285
+ "--no-optional-locks",
286
+ ] as const;
287
+
288
+ const GIT_ENV: NodeJS.ProcessEnv = {
289
+ ...process.env,
290
+ GIT_CONFIG_GLOBAL: "/dev/null",
291
+ GIT_CONFIG_NOSYSTEM: "1",
292
+ GIT_TERMINAL_PROMPT: "0",
293
+ };
294
+
161
295
  function findGitRoot(start: string): string | null {
162
296
  let dir = start;
163
297
  for (let i = 0; i < 12; i += 1) {
@@ -176,14 +310,25 @@ export async function hostGit(rawPath: string): Promise<{
176
310
  }> {
177
311
  try {
178
312
  const start = resolveUserPath(rawPath);
313
+ assertHostPathAllowed(start);
179
314
  const base = statSync(start).isDirectory() ? start : dirname(start);
180
315
  const root = findGitRoot(base);
181
316
  if (!root) throw new StoreError(404, "not a git repository");
182
- const opts = { cwd: root, timeout: 8_000, maxBuffer: GIT_CAP * 2 };
183
- const status = await execFileAsync("git", ["status", "-sb"], opts);
317
+ assertHostPathAllowed(root);
318
+ const opts = {
319
+ cwd: root,
320
+ timeout: 8_000,
321
+ maxBuffer: GIT_CAP * 2,
322
+ env: GIT_ENV,
323
+ };
324
+ const status = await execFileAsync("git", [...GIT_GUARD, "status", "-sb"], opts);
184
325
  let diff = "";
185
326
  try {
186
- const out = await execFileAsync("git", ["diff", "--stat", "HEAD"], opts);
327
+ const out = await execFileAsync(
328
+ "git",
329
+ [...GIT_GUARD, "diff", "--stat", "HEAD"],
330
+ opts,
331
+ );
187
332
  diff = String(out.stdout || "");
188
333
  } catch {
189
334
  diff = "";