@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 +2 -2
- package/src/cli.ts +2 -5
- package/src/db.ts +39 -6
- package/src/generate.ts +3 -3
- package/src/handlers.ts +29 -45
- 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.html +142 -26
- package/src/public/i18n.js +3 -0
- package/src/public/mobile.css +120 -14
- package/src/public/mobile.html +186 -16
- package/src/public/settings.html +51 -8
- package/src/reasoning-catalog.ts +346 -0
- package/src/router.ts +108 -8
- package/src/store.ts +15 -6
- package/src/subagent.ts +10 -5
- package/src/tools.ts +8 -3
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kevin5251984/guild",
|
|
3
|
-
"version": "0.2.
|
|
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": "
|
|
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,
|
|
@@ -753,32 +757,14 @@ function askedText(
|
|
|
753
757
|
return `附件:\n${legend}\n\n${asked}`;
|
|
754
758
|
}
|
|
755
759
|
|
|
756
|
-
/**
|
|
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
|
-
|
|
764
|
+
_userMessage?: { author?: string; body: string; mentions?: string[] },
|
|
761
765
|
): string[] {
|
|
762
766
|
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;
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
:
|
|
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 = "";
|