@kevin5251984/guild 0.2.19 → 0.2.21
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 +2 -2
- package/src/cli.ts +2 -5
- package/src/db.ts +39 -6
- package/src/generate.ts +3 -3
- package/src/handlers.ts +126 -50
- package/src/harness.ts +20 -4
- package/src/host-browse.ts +149 -4
- package/src/llm.ts +37 -15
- package/src/mcp.ts +32 -5
- package/src/mention.ts +80 -12
- package/src/oauth.ts +2 -2
- package/src/opencode-free.ts +3 -2
- package/src/public/chat.css +6 -0
- package/src/public/chat.html +260 -47
- package/src/public/i18n.js +6 -0
- package/src/public/md.js +18 -1
- package/src/public/mobile.css +224 -14
- package/src/public/mobile.html +344 -22
- package/src/public/settings.html +51 -8
- package/src/reasoning-catalog.ts +346 -0
- package/src/router.ts +172 -9
- package/src/store.ts +109 -38
- package/src/subagent.ts +10 -5
- package/src/tools.ts +8 -3
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kevin5251984/guild",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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,
|
|
@@ -518,6 +522,7 @@ export function workspace(store: GuildStore) {
|
|
|
518
522
|
startedAt: turn.startedAt || "",
|
|
519
523
|
thinking: turn.thinking,
|
|
520
524
|
steps: turn.steps,
|
|
525
|
+
...(turn.paused ? { paused: true } : {}),
|
|
521
526
|
},
|
|
522
527
|
];
|
|
523
528
|
});
|
|
@@ -753,32 +758,14 @@ function askedText(
|
|
|
753
758
|
return `附件:\n${legend}\n\n${asked}`;
|
|
754
759
|
}
|
|
755
760
|
|
|
756
|
-
/**
|
|
761
|
+
/** Roster is staffed in the members panel. @handle never pulls a new seat. */
|
|
757
762
|
export function inviteMentionedBots(
|
|
758
763
|
store: GuildStore,
|
|
759
764
|
roomId: string,
|
|
760
|
-
|
|
765
|
+
_userMessage?: { author?: string; body: string; mentions?: string[] },
|
|
761
766
|
): string[] {
|
|
762
767
|
const room = store.getRoom(roomId);
|
|
763
|
-
|
|
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;
|
|
768
|
+
return room?.memberIds ?? [];
|
|
782
769
|
}
|
|
783
770
|
|
|
784
771
|
export function channelMarkdownForRoom(
|
|
@@ -1010,6 +997,7 @@ function publicLiveTurn(live: LiveTurn): LiveTurn {
|
|
|
1010
997
|
thinking: live.thinking,
|
|
1011
998
|
steps: live.steps,
|
|
1012
999
|
startedAt: live.startedAt,
|
|
1000
|
+
...(live.paused ? { paused: true } : {}),
|
|
1013
1001
|
};
|
|
1014
1002
|
}
|
|
1015
1003
|
|
|
@@ -1099,6 +1087,84 @@ export function abortLiveTurn(
|
|
|
1099
1087
|
return { ok: true };
|
|
1100
1088
|
}
|
|
1101
1089
|
|
|
1090
|
+
export function pauseLiveTurn(
|
|
1091
|
+
store: GuildStore,
|
|
1092
|
+
roomId: string,
|
|
1093
|
+
botId?: string,
|
|
1094
|
+
) {
|
|
1095
|
+
if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
|
|
1096
|
+
const live = botId
|
|
1097
|
+
? store.getLiveBotTurn(roomId, botId)
|
|
1098
|
+
: store.getLiveTurn(roomId);
|
|
1099
|
+
if (live?.paused) return { ok: true, paused: true as const };
|
|
1100
|
+
const had = store.pauseTurn(roomId, botId);
|
|
1101
|
+
if (!live && !had) throw new StoreError(409, "no live turn");
|
|
1102
|
+
return { ok: true, paused: true as const };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function resumeSteer(live: LiveTurn): string {
|
|
1106
|
+
const lines = [
|
|
1107
|
+
"Paused mid-turn so the user could switch models. Continue from here. Do not redo finished tools unless you need a different result.",
|
|
1108
|
+
];
|
|
1109
|
+
const thinking = (live.thinking || "").trim();
|
|
1110
|
+
if (thinking) lines.push(`Thinking so far:\n${thinking.slice(0, 6000)}`);
|
|
1111
|
+
const tools = (live.traces || []).filter((tr) => tr.name && tr.name !== "think");
|
|
1112
|
+
if (tools.length) {
|
|
1113
|
+
lines.push(
|
|
1114
|
+
"Tools already run:\n" +
|
|
1115
|
+
tools
|
|
1116
|
+
.slice(-20)
|
|
1117
|
+
.map((tr) => {
|
|
1118
|
+
const bit = String(tr.text || "")
|
|
1119
|
+
.replace(/\s+/g, " ")
|
|
1120
|
+
.trim()
|
|
1121
|
+
.slice(0, 400);
|
|
1122
|
+
return `- ${tr.name}${bit ? `: ${bit}` : ""}`;
|
|
1123
|
+
})
|
|
1124
|
+
.join("\n"),
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
return lines.join("\n\n");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export async function continueLiveTurn(
|
|
1131
|
+
store: GuildStore,
|
|
1132
|
+
roomId: string,
|
|
1133
|
+
botId: string,
|
|
1134
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
1135
|
+
extras: HandlerExtras = {},
|
|
1136
|
+
) {
|
|
1137
|
+
if (!store.getRoom(roomId)) throw new StoreError(404, "room not found");
|
|
1138
|
+
const id = botId.trim();
|
|
1139
|
+
if (!id) throw new StoreError(400, "botId is required");
|
|
1140
|
+
const live = store.getLiveBotTurn(roomId, id);
|
|
1141
|
+
if (!live?.paused) throw new StoreError(409, "no paused turn");
|
|
1142
|
+
const messages = store.listMessages(roomId);
|
|
1143
|
+
let userIndex = messages.length - 1;
|
|
1144
|
+
while (userIndex >= 0 && messages[userIndex].author !== "you") userIndex -= 1;
|
|
1145
|
+
if (userIndex < 0) throw new StoreError(400, "no user message to continue");
|
|
1146
|
+
const userMessage = messages[userIndex];
|
|
1147
|
+
const asked = live.asked?.trim() || userMessage.body.trim();
|
|
1148
|
+
if (!asked) throw new StoreError(409, "nothing to continue");
|
|
1149
|
+
const history = messages.slice(0, userIndex).map(toHistoryItem);
|
|
1150
|
+
const parent = parentMessage(messages.slice(0, userIndex), userMessage.replyTo);
|
|
1151
|
+
const room = store.getRoom(roomId);
|
|
1152
|
+
store.setLiveTurn(roomId, { ...live, paused: false });
|
|
1153
|
+
store.pushSteer(roomId, resumeSteer(live), id);
|
|
1154
|
+
const replies = await generateReplies(
|
|
1155
|
+
store,
|
|
1156
|
+
roomId,
|
|
1157
|
+
room?.memberIds ?? [id],
|
|
1158
|
+
{ ...userMessage, body: asked },
|
|
1159
|
+
history,
|
|
1160
|
+
id,
|
|
1161
|
+
env,
|
|
1162
|
+
parent,
|
|
1163
|
+
extras,
|
|
1164
|
+
);
|
|
1165
|
+
return { replies };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1102
1168
|
function isAbortError(err: unknown): boolean {
|
|
1103
1169
|
return Boolean(
|
|
1104
1170
|
err &&
|
|
@@ -1180,17 +1246,22 @@ async function generateReplies(
|
|
|
1180
1246
|
turnAsked: string,
|
|
1181
1247
|
turnHistory: HistoryItem[],
|
|
1182
1248
|
) => {
|
|
1183
|
-
if (!memberIds.includes(botId)
|
|
1249
|
+
if (!memberIds.includes(botId)) return;
|
|
1184
1250
|
if (signal.aborted) return;
|
|
1185
1251
|
const prev = store.getLiveBotTurn(roomId, botId);
|
|
1252
|
+
if (!prev || prev.paused) return;
|
|
1186
1253
|
const startedAt = prev?.startedAt || new Date().toISOString();
|
|
1187
1254
|
store.dropLastFailedReply(roomId, botId);
|
|
1188
1255
|
store.setLiveTurn(roomId, {
|
|
1189
1256
|
botId,
|
|
1190
1257
|
thinking: prev?.thinking || "",
|
|
1191
1258
|
steps: prev?.steps || [],
|
|
1259
|
+
traces: prev?.traces,
|
|
1260
|
+
asked: turnAsked,
|
|
1192
1261
|
startedAt,
|
|
1262
|
+
paused: false,
|
|
1193
1263
|
});
|
|
1264
|
+
const botSignal = store.armBotTurn(roomId, botId, signal);
|
|
1194
1265
|
let generated;
|
|
1195
1266
|
try {
|
|
1196
1267
|
generated = await (extras.turn ?? chatReply)({
|
|
@@ -1203,10 +1274,11 @@ async function generateReplies(
|
|
|
1203
1274
|
userMessage.body,
|
|
1204
1275
|
),
|
|
1205
1276
|
env,
|
|
1206
|
-
signal,
|
|
1277
|
+
signal: botSignal,
|
|
1207
1278
|
mcpTools,
|
|
1208
1279
|
onProgress: (update) => {
|
|
1209
1280
|
const prev = store.getLiveBotTurn(roomId, botId);
|
|
1281
|
+
if (prev?.paused) return;
|
|
1210
1282
|
const next = toLiveTurn(botId, update);
|
|
1211
1283
|
const handoff = (prev?.steps || []).find((step) => step.name === "handoff");
|
|
1212
1284
|
const pendingSteers = store.peekSteers(roomId, botId).map((text) => ({
|
|
@@ -1223,14 +1295,16 @@ async function generateReplies(
|
|
|
1223
1295
|
);
|
|
1224
1296
|
store.setLiveTurn(roomId, {
|
|
1225
1297
|
...next,
|
|
1298
|
+
asked: prev?.asked || turnAsked,
|
|
1226
1299
|
startedAt: prev?.startedAt || startedAt,
|
|
1300
|
+
paused: false,
|
|
1227
1301
|
steps: [...(handoff ? [handoff] : []), ...keptSteer, ...rest].slice(0, 5),
|
|
1228
1302
|
});
|
|
1229
1303
|
},
|
|
1230
1304
|
pullSteers: () => store.drainSteers(roomId, botId),
|
|
1231
1305
|
});
|
|
1232
1306
|
} catch (err) {
|
|
1233
|
-
if (isAbortError(err) || signal.aborted) return;
|
|
1307
|
+
if (isAbortError(err) || botSignal.aborted || signal.aborted) return;
|
|
1234
1308
|
throw err;
|
|
1235
1309
|
}
|
|
1236
1310
|
const usage = { ...(generated.usage || {}), startedAt };
|
|
@@ -1418,13 +1492,17 @@ function plantLiveTurns(
|
|
|
1418
1492
|
const startedAt = new Date().toISOString();
|
|
1419
1493
|
for (const botId of botIds) {
|
|
1420
1494
|
if (!botId) continue;
|
|
1421
|
-
if (!memberIds.includes(botId)
|
|
1495
|
+
if (!memberIds.includes(botId)) continue;
|
|
1422
1496
|
store.dropLastFailedReply(roomId, botId);
|
|
1497
|
+
const prev = store.getLiveBotTurn(roomId, botId);
|
|
1423
1498
|
store.setLiveTurn(roomId, {
|
|
1424
1499
|
botId,
|
|
1425
|
-
thinking: "",
|
|
1426
|
-
steps: [],
|
|
1427
|
-
|
|
1500
|
+
thinking: prev?.thinking || "",
|
|
1501
|
+
steps: prev?.steps || [],
|
|
1502
|
+
traces: prev?.traces,
|
|
1503
|
+
asked: prev?.asked,
|
|
1504
|
+
startedAt: prev?.startedAt || startedAt,
|
|
1505
|
+
paused: false,
|
|
1428
1506
|
});
|
|
1429
1507
|
}
|
|
1430
1508
|
return startedAt;
|
|
@@ -1482,18 +1560,13 @@ function hasExplicitSummon(
|
|
|
1482
1560
|
}
|
|
1483
1561
|
|
|
1484
1562
|
function includeFollowBot(
|
|
1485
|
-
|
|
1486
|
-
|
|
1563
|
+
_store: GuildStore,
|
|
1564
|
+
_roomId: string,
|
|
1487
1565
|
memberIds: string[],
|
|
1488
1566
|
follow?: string,
|
|
1489
1567
|
): string[] {
|
|
1490
1568
|
if (!follow || memberIds.includes(follow)) return memberIds;
|
|
1491
|
-
|
|
1492
|
-
store.addMember(roomId, follow);
|
|
1493
|
-
return [...memberIds, follow];
|
|
1494
|
-
} catch {
|
|
1495
|
-
return memberIds;
|
|
1496
|
-
}
|
|
1569
|
+
return memberIds;
|
|
1497
1570
|
}
|
|
1498
1571
|
|
|
1499
1572
|
function inviteAssignee(
|
|
@@ -1504,12 +1577,10 @@ function inviteAssignee(
|
|
|
1504
1577
|
const room = store.getRoom(roomId);
|
|
1505
1578
|
if (!room) return [];
|
|
1506
1579
|
if (room.kind !== "channel") return room.memberIds;
|
|
1507
|
-
if (room.memberIds.includes(assigneeId)) return room.memberIds;
|
|
1508
1580
|
if (!store.getBot(assigneeId)) {
|
|
1509
1581
|
throw new StoreError(400, "assignee does not exist");
|
|
1510
1582
|
}
|
|
1511
|
-
|
|
1512
|
-
return [...room.memberIds, assigneeId];
|
|
1583
|
+
return room.memberIds;
|
|
1513
1584
|
}
|
|
1514
1585
|
|
|
1515
1586
|
export async function postUserMessage(
|
|
@@ -1730,4 +1801,9 @@ export async function retryMessage(
|
|
|
1730
1801
|
|
|
1731
1802
|
export { StoreError, localGenerate };
|
|
1732
1803
|
|
|
1733
|
-
export {
|
|
1804
|
+
export {
|
|
1805
|
+
publicModels,
|
|
1806
|
+
mergeModelsFile,
|
|
1807
|
+
refreshOpenCodeFreeCatalog,
|
|
1808
|
+
refreshReasoningCatalog,
|
|
1809
|
+
} 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
|
-
:
|
|
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
|
-
|
|
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 = {
|
package/src/host-browse.ts
CHANGED
|
@@ -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
|
-
|
|
183
|
-
const
|
|
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(
|
|
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 = "";
|