allaboard 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's folder trust, read before a session starts in a bound directory.
|
|
3
|
+
*
|
|
4
|
+
* An SDK session never shows the trust dialog, and Claude Code uses a repository's own hooks, `env`
|
|
5
|
+
* block and helper commands there whether the folder was trusted or not (permissions reference,
|
|
6
|
+
* "What runs before you trust a folder", read 2026-10-08). The daemon therefore starts Claude Code
|
|
7
|
+
* only where the owner has already trusted the folder in the terminal, by the rules Claude Code
|
|
8
|
+
* documents:
|
|
9
|
+
*
|
|
10
|
+
* - in a git repository, the trust is keyed on the repository root (the main checkout's root for
|
|
11
|
+
* a worktree) and a trusted parent does not cover it;
|
|
12
|
+
* - outside a repository, a trusted folder covers its subdirectories.
|
|
13
|
+
*
|
|
14
|
+
* The record is `projects["<path>"].hasTrustDialogAccepted` in `~/.claude.json`, or in
|
|
15
|
+
* `<CLAUDE_CONFIG_DIR>/.claude.json` for a login that has its own folder (`recordPath`).
|
|
16
|
+
*
|
|
17
|
+
* Claude Code holds one kind of content to a stricter rule than this gate: the `permissions.allow`
|
|
18
|
+
* rules and `additionalDirectories` of a folder's `.claude/settings.json` need that folder's own
|
|
19
|
+
* record, and a trusted parent does not stand for it (same table). In a folder that only a parent's
|
|
20
|
+
* trust covers, a session starts and those rules are left out, with `this workspace has not been
|
|
21
|
+
* trusted` on stderr (measured 2026-10-03, CLI 2.1.286). The terminal shows its dialog again there,
|
|
22
|
+
* listing them, whatever the session did. No SDK or `claude -p` run wrote or changed a record's
|
|
23
|
+
* `hasTrustDialogAccepted` in those measurements; an interactive start that is not trusted writes
|
|
24
|
+
* `false`.
|
|
25
|
+
*
|
|
26
|
+
* The daemon's own git (the footer, `!status`, `!open`) runs under the first rule alone: in a
|
|
27
|
+
* repository whose key the owner trusted (`trustedRepository`).
|
|
28
|
+
*
|
|
29
|
+
* Which repository a folder belongs to is read from the filesystem and never asked of git there:
|
|
30
|
+
* git would answer from the folder's own `.git` file, `commondir` and `core.worktree`, which
|
|
31
|
+
* whoever supplied the folder wrote. The layout read here is the one gitrepository-layout(5) and
|
|
32
|
+
* git-worktree(1) document, and the bare-repository test follows `is_git_directory` in git's
|
|
33
|
+
* setup.c (git 2.54.0, read 2026-10-04). The two small files it reads (a `.git` file and a
|
|
34
|
+
* worktree's `gitdir`) are cut at their line ends as git cuts them (setup.c and
|
|
35
|
+
* worktree.c, git 2.54.0, read 2026-10-06): a different cut names a different path than git's.
|
|
36
|
+
*
|
|
37
|
+
* Every check runs on `node:fs/promises`, so a slow disk never holds the event loop. The paths
|
|
38
|
+
* are compared as directories on disk by device and inode (`stat` with `bigint`, since a 64-bit
|
|
39
|
+
* inode does not fit a number), never by string.
|
|
40
|
+
*/
|
|
41
|
+
import { constants } from "node:fs";
|
|
42
|
+
import { lstat, open, readFile, readlink, stat } from "node:fs/promises";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
45
|
+
import { getLogger } from "../../log.js";
|
|
46
|
+
import { errorName } from "../../values.js";
|
|
47
|
+
import { recordPath } from "./record.js";
|
|
48
|
+
export const logger = getLogger("allaboard.agent.claude.trust");
|
|
49
|
+
/** git's own limit for a `.git` file (setup.c, read_gitfile_gently). */
|
|
50
|
+
export const GITFILE_BYTES = 1 << 20;
|
|
51
|
+
/**
|
|
52
|
+
* A nested resolution deeper than this gives up, as Python 3.12's `realpath` does with a
|
|
53
|
+
* `RecursionError` on a long enough chain of symlinks. The kernel follows 40 links at most.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_SYMLINK_DEPTH = 255;
|
|
56
|
+
/**
|
|
57
|
+
* git would find a repository at the folder that no path the owner trusted can stand for: a bare
|
|
58
|
+
* layout, or metadata that cannot be read.
|
|
59
|
+
*/
|
|
60
|
+
export class Unkeyed extends Error {
|
|
61
|
+
constructor(cause) {
|
|
62
|
+
super("no path the owner trusted can stand for this repository", { cause });
|
|
63
|
+
this.name = "Unkeyed";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
class SymlinkChainTooDeep extends Error {
|
|
67
|
+
constructor() {
|
|
68
|
+
super("too many nested symbolic links");
|
|
69
|
+
this.name = "SymlinkChainTooDeep";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// A system error is an error with a `syscall`; a NUL in a path is Node's
|
|
73
|
+
// `ERR_INVALID_ARG_VALUE`, which `isValueError` takes.
|
|
74
|
+
function isOsError(error) {
|
|
75
|
+
return error instanceof Error && typeof error.syscall === "string";
|
|
76
|
+
}
|
|
77
|
+
function isValueError(error) {
|
|
78
|
+
return error instanceof Error && error.code === "ERR_INVALID_ARG_VALUE";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* What a path somebody else wrote can do to a lookup: a system error, a NUL in it, or a chain of
|
|
82
|
+
* symlinks `realpath` gives up on. An exported function answers closed on any of them.
|
|
83
|
+
*/
|
|
84
|
+
function isFilesystemOddity(error) {
|
|
85
|
+
return isOsError(error) || isValueError(error) || error instanceof SymlinkChainTooDeep;
|
|
86
|
+
}
|
|
87
|
+
function isMissing(error) {
|
|
88
|
+
const code = error?.code;
|
|
89
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
90
|
+
}
|
|
91
|
+
// A replacement character would let `m\xff` name a folder called `m\ufffd`. A path read from a
|
|
92
|
+
// file is decoded strictly, and one that is not UTF-8 is metadata this module cannot use.
|
|
93
|
+
const FATAL_UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
94
|
+
function decodePath(bytes) {
|
|
95
|
+
try {
|
|
96
|
+
return FATAL_UTF8.decode(bytes);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Strict UTF-8, and a BOM stays in the text, where `JSON.parse` refuses it.
|
|
103
|
+
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
104
|
+
function sameStat(a, b) {
|
|
105
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
106
|
+
}
|
|
107
|
+
// What separates the parts of a path: both slashes on Windows, where `/` is accepted too (git
|
|
108
|
+
// writes `gitdir: C:/...`), and `/` alone on POSIX, where a backslash is a file name character.
|
|
109
|
+
const SEPARATOR = sep === "\\" ? /[\\/]/ : /\//;
|
|
110
|
+
const ENDS_WITH_SEPARATOR = sep === "\\" ? /[\\/]$/ : /\/$/;
|
|
111
|
+
/** `base / name` as pathlib joins it: an absolute name replaces the base, `.` parts go, `..` stays. */
|
|
112
|
+
function joinPath(base, name) {
|
|
113
|
+
const joined = isAbsolute(name) ? name : `${base}${sep}${name}`;
|
|
114
|
+
const { root } = parse(joined);
|
|
115
|
+
const parts = joined
|
|
116
|
+
.slice(root.length)
|
|
117
|
+
.split(SEPARATOR)
|
|
118
|
+
.filter((part) => part !== "" && part !== ".");
|
|
119
|
+
return root + parts.join(sep);
|
|
120
|
+
}
|
|
121
|
+
/** `os.path.join` for a name that is not absolute, with the host's separator. */
|
|
122
|
+
function joinSlash(path, name) {
|
|
123
|
+
return ENDS_WITH_SEPARATOR.test(path) ? path + name : `${path}${sep}${name}`;
|
|
124
|
+
}
|
|
125
|
+
/** The folder and every folder above it, nearest first. */
|
|
126
|
+
function withParents(folder) {
|
|
127
|
+
const chain = [folder];
|
|
128
|
+
for (let up = dirname(folder); up !== chain[chain.length - 1]; up = dirname(up)) {
|
|
129
|
+
chain.push(up);
|
|
130
|
+
}
|
|
131
|
+
return chain;
|
|
132
|
+
}
|
|
133
|
+
async function joinRealpath(start, target, seen, depth) {
|
|
134
|
+
if (depth > MAX_SYMLINK_DEPTH) {
|
|
135
|
+
throw new SymlinkChainTooDeep();
|
|
136
|
+
}
|
|
137
|
+
let path = start;
|
|
138
|
+
let rest = target;
|
|
139
|
+
const { root } = parse(rest);
|
|
140
|
+
if (root !== "") {
|
|
141
|
+
rest = rest.slice(root.length);
|
|
142
|
+
path = root;
|
|
143
|
+
}
|
|
144
|
+
while (rest) {
|
|
145
|
+
const cut = rest.search(SEPARATOR);
|
|
146
|
+
const name = cut === -1 ? rest : rest.slice(0, cut);
|
|
147
|
+
rest = cut === -1 ? "" : rest.slice(cut + 1);
|
|
148
|
+
if (name === "" || name === ".") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (name === "..") {
|
|
152
|
+
path = dirname(path);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const newPath = joinSlash(path, name);
|
|
156
|
+
let isLink = false;
|
|
157
|
+
try {
|
|
158
|
+
isLink = (await lstat(newPath)).isSymbolicLink();
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
// Not strict: a component that cannot be looked at is kept as it is.
|
|
162
|
+
if (!isOsError(error)) {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!isLink) {
|
|
167
|
+
path = newPath;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (seen.has(newPath)) {
|
|
171
|
+
const cached = seen.get(newPath);
|
|
172
|
+
if (cached !== undefined && cached !== null) {
|
|
173
|
+
path = cached;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// The symlink is not resolved, so there is a loop: the part resolved so far, the rest as is.
|
|
177
|
+
return [joinSlash(newPath, rest), false];
|
|
178
|
+
}
|
|
179
|
+
seen.set(newPath, null);
|
|
180
|
+
const [resolved, ok] = await joinRealpath(path, await readlink(newPath), seen, depth + 1);
|
|
181
|
+
path = resolved;
|
|
182
|
+
if (!ok) {
|
|
183
|
+
return [joinSlash(path, rest), false];
|
|
184
|
+
}
|
|
185
|
+
seen.set(newPath, path);
|
|
186
|
+
}
|
|
187
|
+
return [path, true];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `os.path.realpath` as Python 3.12 does it, not `fs.realpath`: symlinks are resolved and a tail
|
|
191
|
+
* that does not exist is kept, where `fs.realpath` throws on a missing path.
|
|
192
|
+
*/
|
|
193
|
+
async function realpath(filename) {
|
|
194
|
+
// The path goes to the walk as it is, never through `resolve`: that would fold a `..` into the
|
|
195
|
+
// component before it, and the kernel, git and Python's `_joinrealpath` follow the symlink first.
|
|
196
|
+
const absolute = isAbsolute(filename) ? filename : `${process.cwd()}${sep}${filename}`;
|
|
197
|
+
const [path] = await joinRealpath(parse(absolute).root, absolute, new Map(), 0);
|
|
198
|
+
// Python's `abspath` at the end: lexical, as `resolve` is.
|
|
199
|
+
return resolve(path);
|
|
200
|
+
}
|
|
201
|
+
/** `path`'s own entry, a symlink not followed; null when nothing is there. */
|
|
202
|
+
async function entryStat(path) {
|
|
203
|
+
try {
|
|
204
|
+
return await lstat(path);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (isMissing(error)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
throw new Unkeyed(error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function lexists(path) {
|
|
214
|
+
try {
|
|
215
|
+
await lstat(path);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** A regular file's content. Never waits on a FIFO, never follows a symlink. */
|
|
223
|
+
async function regularFile(path) {
|
|
224
|
+
let handle;
|
|
225
|
+
try {
|
|
226
|
+
// `O_NOFOLLOW` and `O_NONBLOCK` do not exist on Windows, where the daemon is not supported.
|
|
227
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const found = await handle.stat();
|
|
234
|
+
if (!found.isFile() || found.size > GITFILE_BYTES) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
// Up to the limit, not up to `found.size`: a file that grew since the `fstat` is read whole,
|
|
238
|
+
// where a prefix would name another path.
|
|
239
|
+
const buffer = Buffer.allocUnsafe(GITFILE_BYTES);
|
|
240
|
+
const { bytesRead } = await handle.read(buffer, 0, GITFILE_BYTES, 0);
|
|
241
|
+
return buffer.subarray(0, bytesRead);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await handle.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** `bytes` without the trailing bytes that are any of `strip`, as `bytes.rstrip(strip)`. */
|
|
251
|
+
function stripEnd(bytes, strip) {
|
|
252
|
+
let end = bytes.length;
|
|
253
|
+
while (end > 0 && strip.includes(bytes[end - 1])) {
|
|
254
|
+
end -= 1;
|
|
255
|
+
}
|
|
256
|
+
return bytes.subarray(0, end);
|
|
257
|
+
}
|
|
258
|
+
const CR = 0x0d;
|
|
259
|
+
const LF = 0x0a;
|
|
260
|
+
const SPACE = 0x20;
|
|
261
|
+
const TAB = 0x09;
|
|
262
|
+
/**
|
|
263
|
+
* Whether git could take `folder` for a git dir: it wants a HEAD, and `objects` and `refs` there
|
|
264
|
+
* or in the directory a `commondir` names. Every folder git accepts passes here; a test holds
|
|
265
|
+
* that against the git installed.
|
|
266
|
+
*/
|
|
267
|
+
async function gitDirLike(folder) {
|
|
268
|
+
if (!(await lexists(`${folder}/HEAD`))) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (await lexists(`${folder}/commondir`)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return (await lexists(`${folder}/objects`)) && (await lexists(`${folder}/refs`));
|
|
275
|
+
}
|
|
276
|
+
/** The git dir a `.git` file names, read as git reads it. */
|
|
277
|
+
async function gitfileTarget(gitfile) {
|
|
278
|
+
const content = await regularFile(gitfile);
|
|
279
|
+
if (content === null || !content.subarray(0, 8).equals(Buffer.from("gitdir: "))) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
// git strips line ends only: a trailing space belongs to the path.
|
|
283
|
+
const target = decodePath(stripEnd(content.subarray(8), [CR, LF]));
|
|
284
|
+
return target ? realpath(joinPath(dirname(gitfile), target)) : null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The main checkout that registers `root` as a linked worktree, null when none does.
|
|
288
|
+
*
|
|
289
|
+
* The link is read on the main checkout's side, in `<main>/.git/worktrees/<id>/gitdir`, which
|
|
290
|
+
* git writes there; what `root`'s own `.git` file says only tells where to look.
|
|
291
|
+
*/
|
|
292
|
+
async function mainCheckout(gitDir, root) {
|
|
293
|
+
const registry = dirname(gitDir);
|
|
294
|
+
const dotGit = dirname(registry);
|
|
295
|
+
if (basename(registry) !== "worktrees" || basename(dotGit) !== ".git") {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
// A main checkout is a repository by the walk's own test: a `.git` holding a worktree
|
|
299
|
+
// registry and nothing else, left in a trusted folder outside git, makes it none.
|
|
300
|
+
if (!(await gitDirLike(dotGit))) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const content = await regularFile(`${gitDir}/gitdir`);
|
|
304
|
+
if (content === null) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
// An absolute path, or one relative to this directory (`git worktree add --relative-paths`).
|
|
308
|
+
// git strips trailing whitespace here (`strbuf_rtrim`, worktree.c get_linked_worktree), and
|
|
309
|
+
// whitespace is those four bytes for it (ctype.c, `sane_ctype`).
|
|
310
|
+
const name = decodePath(stripEnd(content, [SPACE, TAB, CR, LF]));
|
|
311
|
+
if (name === null) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const registered = joinPath(gitDir, name);
|
|
315
|
+
// The two folders are compared as directories on disk: the same one whatever the case or
|
|
316
|
+
// the Unicode form git wrote, another one for a name one space longer. Not the `.git` files:
|
|
317
|
+
// a file can be hard-linked into a second folder.
|
|
318
|
+
try {
|
|
319
|
+
if (basename(registered) !== ".git" ||
|
|
320
|
+
!sameStat(await stat(dirname(registered), { bigint: true }), await stat(root, { bigint: true }))) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (isOsError(error)) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
return dirname(dotGit);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The repository holding `directory`, null outside git. Rejects with `Unkeyed`.
|
|
334
|
+
*
|
|
335
|
+
* The walk is git's own, with one difference: a folder holding a `.git` entry is keyed on
|
|
336
|
+
* itself, whatever that entry claims, unless a main checkout registers it as its worktree.
|
|
337
|
+
*/
|
|
338
|
+
export async function locate(directory) {
|
|
339
|
+
try {
|
|
340
|
+
return await locateFolder(await realpath(directory));
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
// What a `.git` file or symlink names is somebody else's text: a NUL in it is no path, and
|
|
344
|
+
// `realpath` gives up on a long enough chain of symlinks.
|
|
345
|
+
if (isFilesystemOddity(error)) {
|
|
346
|
+
throw new Unkeyed(error);
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async function locateFolder(folder) {
|
|
352
|
+
if (!(await stat(folder)).isDirectory()) {
|
|
353
|
+
throw new Unkeyed();
|
|
354
|
+
}
|
|
355
|
+
const passed = [];
|
|
356
|
+
let gitDirPassed = null; // the first on the way up, where git would stop
|
|
357
|
+
let found = null;
|
|
358
|
+
for (const root of withParents(folder)) {
|
|
359
|
+
const entry = join(root, ".git");
|
|
360
|
+
const mode = await entryStat(entry);
|
|
361
|
+
// git walks past a `.git` directory that is no git dir.
|
|
362
|
+
if (mode !== null && !(mode.isDirectory() && !(await gitDirLike(entry)))) {
|
|
363
|
+
found = { root, entry, mode };
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (gitDirPassed === null && (await gitDirLike(root))) {
|
|
367
|
+
gitDirPassed = root;
|
|
368
|
+
}
|
|
369
|
+
passed.push(root);
|
|
370
|
+
}
|
|
371
|
+
if (found === null) {
|
|
372
|
+
if (gitDirPassed !== null) {
|
|
373
|
+
throw new Unkeyed();
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const { root, entry, mode } = found;
|
|
378
|
+
if (gitDirPassed !== null) {
|
|
379
|
+
// In the root's own `.git` directory, reached in whatever case, the repository is the
|
|
380
|
+
// root's. Anywhere else it is a bare layout below the root: git stops there and reads
|
|
381
|
+
// that folder's own config.
|
|
382
|
+
const own = await stat(entry, { bigint: true });
|
|
383
|
+
let reached = false;
|
|
384
|
+
if (mode.isDirectory()) {
|
|
385
|
+
for (const path of passed) {
|
|
386
|
+
if (sameStat(own, await stat(path, { bigint: true }))) {
|
|
387
|
+
reached = true;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!reached) {
|
|
393
|
+
throw new Unkeyed();
|
|
394
|
+
}
|
|
395
|
+
return { root, key: root, gitDir: gitDirPassed, insideGitDir: true };
|
|
396
|
+
}
|
|
397
|
+
if (mode.isDirectory()) {
|
|
398
|
+
return { root, key: root, gitDir: entry, insideGitDir: false };
|
|
399
|
+
}
|
|
400
|
+
if (mode.isSymbolicLink()) {
|
|
401
|
+
return { root, key: root, gitDir: await realpath(entry), insideGitDir: false };
|
|
402
|
+
}
|
|
403
|
+
const gitDir = mode.isFile() ? await gitfileTarget(entry) : null;
|
|
404
|
+
const main = gitDir ? await mainCheckout(gitDir, root) : null;
|
|
405
|
+
return { root, key: main ?? root, gitDir, insideGitDir: false };
|
|
406
|
+
}
|
|
407
|
+
// A `!bind` list checks many folders in a row: the record, several MB, is parsed again only once
|
|
408
|
+
// it has changed on disk. The promise is kept, not the result, so the checks of one list that
|
|
409
|
+
// arrive while the first is still reading wait for it instead of parsing the record each.
|
|
410
|
+
let reading = null;
|
|
411
|
+
async function trustedPaths(home, configDir) {
|
|
412
|
+
const record = recordPath(home, configDir);
|
|
413
|
+
let found;
|
|
414
|
+
try {
|
|
415
|
+
found = await stat(record, { bigint: true });
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (!isOsError(error)) {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
logger.warning(`could not read Claude Code's trusted folders: ${errorName(error)}`);
|
|
422
|
+
return new Set();
|
|
423
|
+
}
|
|
424
|
+
const key = `${record}\0${found.mtimeNs}\0${found.size}`;
|
|
425
|
+
if (reading === null || reading.key !== key) {
|
|
426
|
+
reading = { key, trusted: readTrusted(record) };
|
|
427
|
+
}
|
|
428
|
+
return reading.trusted;
|
|
429
|
+
}
|
|
430
|
+
async function readTrusted(recordPath) {
|
|
431
|
+
let record;
|
|
432
|
+
try {
|
|
433
|
+
record = JSON.parse(STRICT_UTF8.decode(await readFile(recordPath)));
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
logger.warning(`could not read Claude Code's trusted folders: ${errorName(error)}`);
|
|
437
|
+
return new Set();
|
|
438
|
+
}
|
|
439
|
+
const projects = typeof record === "object" && record !== null && !Array.isArray(record)
|
|
440
|
+
? record.projects
|
|
441
|
+
: undefined;
|
|
442
|
+
if (typeof projects !== "object" || projects === null || Array.isArray(projects)) {
|
|
443
|
+
return new Set();
|
|
444
|
+
}
|
|
445
|
+
const accepted = new Set();
|
|
446
|
+
for (const [path, state] of Object.entries(projects)) {
|
|
447
|
+
// A lone surrogate cannot be encoded as a path: Node would name the folder with a replacement
|
|
448
|
+
// character. With the `u` flag a pair is one code point, so `\p{Surrogate}` finds only a lone
|
|
449
|
+
// one (`isWellFormed` is not in the project's lib).
|
|
450
|
+
if (!LONE_SURROGATE.test(path) &&
|
|
451
|
+
typeof state === "object" &&
|
|
452
|
+
state !== null &&
|
|
453
|
+
!Array.isArray(state) &&
|
|
454
|
+
state.hasTrustDialogAccepted === true) {
|
|
455
|
+
accepted.add(path);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return accepted;
|
|
459
|
+
}
|
|
460
|
+
const LONE_SURROGATE = /\p{Surrogate}/u;
|
|
461
|
+
const RECORDED_BATCH = 64;
|
|
462
|
+
/**
|
|
463
|
+
* Whether the recorded path is the folder `mine` and spells it: a recorded path stands for the
|
|
464
|
+
* folder it spells, not for one a symlink in it leads to, so none of its components may be a
|
|
465
|
+
* symlink.
|
|
466
|
+
*/
|
|
467
|
+
async function spellsFolder(recorded, mine) {
|
|
468
|
+
try {
|
|
469
|
+
return (sameStat(mine, await lstat(recorded, { bigint: true })) &&
|
|
470
|
+
(await realpath(recorded)) === recorded);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (isFilesystemOddity(error)) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether `path` is a folder the owner trusted: the same directory on disk as a recorded path,
|
|
481
|
+
* so a path in another case or Unicode form is that folder and `app ` is not `app`.
|
|
482
|
+
*/
|
|
483
|
+
async function isTrusted(path, home, configDir) {
|
|
484
|
+
const trusted = await trustedPaths(home, configDir);
|
|
485
|
+
if (trusted.has(path)) {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
let mine;
|
|
489
|
+
try {
|
|
490
|
+
mine = await stat(path, { bigint: true });
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (isOsError(error)) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
// The recorded paths are looked at RECORDED_BATCH at a time: one lstat after the other would
|
|
499
|
+
// cost a round trip to the thread pool each. The verdicts are read in the record's order, so a
|
|
500
|
+
// failure past the first match is not raised, as in a loop that stops there.
|
|
501
|
+
const recorded = [...trusted];
|
|
502
|
+
for (let from = 0; from < recorded.length; from += RECORDED_BATCH) {
|
|
503
|
+
const batch = recorded.slice(from, from + RECORDED_BATCH);
|
|
504
|
+
for (const verdict of await Promise.allSettled(batch.map((one) => spellsFolder(one, mine)))) {
|
|
505
|
+
if (verdict.status === "rejected") {
|
|
506
|
+
throw verdict.reason;
|
|
507
|
+
}
|
|
508
|
+
if (verdict.value) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The repository holding `directory` when the daemon's own git may run in it; null outside git,
|
|
517
|
+
* where the layout has no key, and in a repository whose key is no path the owner trusted in
|
|
518
|
+
* Claude Code (a worktree's key is its main checkout).
|
|
519
|
+
*
|
|
520
|
+
* A trusted folder does not stand for a repository inside it: git runs the commands a
|
|
521
|
+
* repository's own config names (a `clean` filter on `status` and `diff-files`), and a repository
|
|
522
|
+
* can reach a trusted folder in an archive or a clone, with a config the owner never read. The
|
|
523
|
+
* agent may `cd` into one, and `!open` looks for them below the session's folder. Nothing here
|
|
524
|
+
* runs git.
|
|
525
|
+
*/
|
|
526
|
+
export async function trustedRepository(directory, home = homedir(), configDir) {
|
|
527
|
+
let repository;
|
|
528
|
+
try {
|
|
529
|
+
repository = await locate(directory);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (error instanceof Unkeyed) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
if (repository === null) {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
return (await isTrusted(repository.key, home, configDir)) ? repository : null;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Whether the owner trusted `directory` in Claude Code, as the terminal would decide; false where
|
|
544
|
+
* git would find a repository that no trusted path stands for.
|
|
545
|
+
*/
|
|
546
|
+
export async function workspaceTrusted(directory, home = homedir(), configDir) {
|
|
547
|
+
let repository;
|
|
548
|
+
try {
|
|
549
|
+
repository = await locate(directory);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error instanceof Unkeyed) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
if (repository !== null) {
|
|
558
|
+
return isTrusted(repository.key, home, configDir);
|
|
559
|
+
}
|
|
560
|
+
let folder;
|
|
561
|
+
try {
|
|
562
|
+
folder = await realpath(directory);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (isFilesystemOddity(error)) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
for (const path of withParents(folder)) {
|
|
571
|
+
if (await isTrusted(path, home, configDir)) {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account's usage limits, read from `/usage` on a session of their own: the limits belong to
|
|
3
|
+
* the account, and a probe inside a channel's session would add to that session's transcript.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { systemClock } from "../../clock.js";
|
|
7
|
+
import { parseUsage, USAGE_TIMEOUT } from "./usage.js";
|
|
8
|
+
const REFUSAL = "The usage probe asks for nothing.";
|
|
9
|
+
// `/usage` runs inside Claude Code and asks nothing; should it ever, the answer is no.
|
|
10
|
+
const NO_ANSWERS = {
|
|
11
|
+
permission: async () => ({ allow: false, message: REFUSAL }),
|
|
12
|
+
question: async () => ({ answered: false, message: REFUSAL }),
|
|
13
|
+
};
|
|
14
|
+
export class UsageProbe {
|
|
15
|
+
#folder;
|
|
16
|
+
#start;
|
|
17
|
+
#clock;
|
|
18
|
+
#timeout;
|
|
19
|
+
#now;
|
|
20
|
+
#session = null;
|
|
21
|
+
#events = null;
|
|
22
|
+
// Counts the closes, so a session still starting when one happens is closed as it arrives.
|
|
23
|
+
#epoch = 0;
|
|
24
|
+
#stopped = false;
|
|
25
|
+
/** `start` is the back end's bare one: the probe session takes nothing of the owner's. */
|
|
26
|
+
constructor(folder, start, options = {}) {
|
|
27
|
+
this.#folder = folder;
|
|
28
|
+
this.#start = start;
|
|
29
|
+
this.#clock = options.clock ?? systemClock;
|
|
30
|
+
this.#timeout = options.timeout ?? USAGE_TIMEOUT;
|
|
31
|
+
this.#now = options.now ?? Date.now;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The limits `/usage` reports now. One timeout covers the whole call; on any failure, the
|
|
35
|
+
* timeout included, the session is closed and the failure rethrown: a session left mid-turn
|
|
36
|
+
* would answer the next call late.
|
|
37
|
+
*/
|
|
38
|
+
async read() {
|
|
39
|
+
if (this.#stopped)
|
|
40
|
+
throw new Error("the usage probe is stopped");
|
|
41
|
+
const limit = new AbortController();
|
|
42
|
+
// Settles only by running out: the sleep's own rejection is its cancellation.
|
|
43
|
+
const expiry = new Promise((_, reject) => {
|
|
44
|
+
this.#clock.sleep(this.#timeout / 1000, limit.signal).then(() => reject(new DOMException("the usage probe had no answer in time", "TimeoutError")), () => undefined);
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([this.#ask(), expiry]);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await this.close();
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
limit.abort();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Closes the probe's session, if one is open; the next `read` starts another. */
|
|
58
|
+
async close() {
|
|
59
|
+
this.#epoch += 1;
|
|
60
|
+
const session = this.#session;
|
|
61
|
+
this.#session = null;
|
|
62
|
+
this.#events = null;
|
|
63
|
+
if (session !== null)
|
|
64
|
+
await session.close();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The daemon's last close: the session is closed and no `read` starts another. A footer
|
|
68
|
+
* refresh that a listener in flight still asks for would otherwise start a Claude Code process
|
|
69
|
+
* nobody closes.
|
|
70
|
+
*/
|
|
71
|
+
async stop() {
|
|
72
|
+
this.#stopped = true;
|
|
73
|
+
await this.close();
|
|
74
|
+
}
|
|
75
|
+
async #ask() {
|
|
76
|
+
const epoch = this.#epoch;
|
|
77
|
+
if (this.#session === null) {
|
|
78
|
+
const session = await this.#start(this.#folder, NO_ANSWERS);
|
|
79
|
+
if (epoch !== this.#epoch) {
|
|
80
|
+
await session.close();
|
|
81
|
+
throw new Error("the usage probe was closed while it started");
|
|
82
|
+
}
|
|
83
|
+
this.#session = session;
|
|
84
|
+
this.#events = session.events[Symbol.asyncIterator]();
|
|
85
|
+
}
|
|
86
|
+
const session = this.#session;
|
|
87
|
+
const events = this.#events;
|
|
88
|
+
if (events === null)
|
|
89
|
+
throw new Error("the usage probe was closed");
|
|
90
|
+
await session.send({ id: randomUUID(), content: "/usage" });
|
|
91
|
+
for (;;) {
|
|
92
|
+
const next = await events.next();
|
|
93
|
+
if (next.done)
|
|
94
|
+
throw new Error("the usage probe's session ended");
|
|
95
|
+
const event = next.value;
|
|
96
|
+
if (event.type === "turn_ended") {
|
|
97
|
+
return parseUsage(event.finalText ?? "", this.#now());
|
|
98
|
+
}
|
|
99
|
+
if (event.type === "process_lost")
|
|
100
|
+
throw new Error("the usage probe lost its process");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|