@henryqw/pi-herdr-btw 1.0.1
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 +78 -0
- package/extensions/btw.ts +829 -0
- package/internal/config.ts +253 -0
- package/internal/context-store.ts +287 -0
- package/internal/core.ts +393 -0
- package/internal/merge.ts +317 -0
- package/internal/router.ts +42 -0
- package/package.json +52 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export const TOOL_MODES = ["inherit", "all", "read-only", "none"] as const;
|
|
7
|
+
export type BtwToolMode = (typeof TOOL_MODES)[number];
|
|
8
|
+
export type BtwSplit = "right" | "down";
|
|
9
|
+
|
|
10
|
+
export type BtwConfig = {
|
|
11
|
+
autoSubmit: boolean;
|
|
12
|
+
tools: BtwToolMode;
|
|
13
|
+
split: BtwSplit;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const CONFIG_LOCK_STALE_MS = 30_000;
|
|
17
|
+
const CONFIG_LOCK_WAIT_MS = 10_000;
|
|
18
|
+
const CONFIG_LOCK_RETRY_MS = 25;
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_CONFIG: Readonly<BtwConfig> = Object.freeze({
|
|
21
|
+
autoSubmit: false,
|
|
22
|
+
tools: "inherit",
|
|
23
|
+
split: "right",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseConfig(value: unknown): BtwConfig {
|
|
31
|
+
if (!isRecord(value)) throw new Error("/btw config must be a JSON object");
|
|
32
|
+
const allowedKeys = new Set(["autoSubmit", "tools", "split"]);
|
|
33
|
+
for (const key of Object.keys(value)) {
|
|
34
|
+
if (!allowedKeys.has(key)) throw new Error(`unknown config key: ${key}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const config = { ...DEFAULT_CONFIG };
|
|
38
|
+
if ("autoSubmit" in value) {
|
|
39
|
+
if (typeof value.autoSubmit !== "boolean") throw new Error("autoSubmit must be true or false");
|
|
40
|
+
config.autoSubmit = value.autoSubmit;
|
|
41
|
+
}
|
|
42
|
+
if ("tools" in value) {
|
|
43
|
+
if (!TOOL_MODES.includes(value.tools as BtwToolMode)) {
|
|
44
|
+
throw new Error("tools must be inherit, all, read-only, or none");
|
|
45
|
+
}
|
|
46
|
+
config.tools = value.tools as BtwToolMode;
|
|
47
|
+
}
|
|
48
|
+
if ("split" in value) {
|
|
49
|
+
if (value.split !== "right" && value.split !== "down") {
|
|
50
|
+
throw new Error("split must be right or down");
|
|
51
|
+
}
|
|
52
|
+
config.split = value.split;
|
|
53
|
+
}
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatConfig(config: BtwConfig): string {
|
|
58
|
+
return [
|
|
59
|
+
`auto-submit: ${config.autoSubmit ? "on" : "off"}`,
|
|
60
|
+
`tools: ${config.tools}`,
|
|
61
|
+
`split: ${config.split}`,
|
|
62
|
+
].join(" · ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const CONFIG_COMMAND_USAGE =
|
|
66
|
+
"/btw config [auto-submit on|off | tools inherit|all|read-only|none | split right|down | reset]";
|
|
67
|
+
|
|
68
|
+
export type ConfigCommandResult = {
|
|
69
|
+
action: "show" | "save" | "reset";
|
|
70
|
+
config: BtwConfig;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function applyConfigCommand(current: BtwConfig, input: string): ConfigCommandResult {
|
|
74
|
+
const trimmed = input.trim();
|
|
75
|
+
if (!trimmed || trimmed === "show") return { action: "show", config: current };
|
|
76
|
+
if (trimmed === "reset") return { action: "reset", config: { ...DEFAULT_CONFIG } };
|
|
77
|
+
|
|
78
|
+
const [key, value, ...extra] = trimmed.split(/\s+/);
|
|
79
|
+
if (!key || !value || extra.length > 0) throw new Error(CONFIG_COMMAND_USAGE);
|
|
80
|
+
const config = { ...current };
|
|
81
|
+
|
|
82
|
+
switch (key) {
|
|
83
|
+
case "auto-submit":
|
|
84
|
+
if (value !== "on" && value !== "off") throw new Error(CONFIG_COMMAND_USAGE);
|
|
85
|
+
config.autoSubmit = value === "on";
|
|
86
|
+
break;
|
|
87
|
+
case "tools":
|
|
88
|
+
if (!TOOL_MODES.includes(value as BtwToolMode)) {
|
|
89
|
+
throw new Error(CONFIG_COMMAND_USAGE);
|
|
90
|
+
}
|
|
91
|
+
config.tools = value as BtwToolMode;
|
|
92
|
+
break;
|
|
93
|
+
case "split":
|
|
94
|
+
if (value !== "right" && value !== "down") throw new Error(CONFIG_COMMAND_USAGE);
|
|
95
|
+
config.split = value;
|
|
96
|
+
break;
|
|
97
|
+
default:
|
|
98
|
+
throw new Error(CONFIG_COMMAND_USAGE);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { action: "save", config };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const configPath = () => join(getAgentDir(), "config", "pi-herdr-btw.json");
|
|
105
|
+
|
|
106
|
+
export class ConfigStore {
|
|
107
|
+
readonly path: string;
|
|
108
|
+
|
|
109
|
+
constructor(path = configPath()) {
|
|
110
|
+
this.path = path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async load(): Promise<BtwConfig> {
|
|
114
|
+
try {
|
|
115
|
+
return parseConfig(JSON.parse(await readFile(this.path, "utf8")));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
118
|
+
return { ...DEFAULT_CONFIG };
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async save(config: BtwConfig): Promise<void> {
|
|
125
|
+
const validated = parseConfig(config);
|
|
126
|
+
await this.withLock(() => this.saveUnlocked(validated));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async update(mutator: (config: BtwConfig) => BtwConfig): Promise<BtwConfig> {
|
|
130
|
+
return this.withLock(async () => {
|
|
131
|
+
const next = parseConfig(mutator(await this.load()));
|
|
132
|
+
await this.saveUnlocked(next);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async reset(): Promise<BtwConfig> {
|
|
138
|
+
return this.withLock(async () => {
|
|
139
|
+
await rm(this.path, { force: true });
|
|
140
|
+
return { ...DEFAULT_CONFIG };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async saveUnlocked(validated: BtwConfig): Promise<void> {
|
|
145
|
+
const directory = dirname(this.path);
|
|
146
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
147
|
+
const temporaryPath = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
flag: "wx",
|
|
152
|
+
mode: 0o600,
|
|
153
|
+
});
|
|
154
|
+
await chmod(temporaryPath, 0o600);
|
|
155
|
+
await rename(temporaryPath, this.path);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
163
|
+
const release = await this.acquireLock();
|
|
164
|
+
try {
|
|
165
|
+
return await operation();
|
|
166
|
+
} finally {
|
|
167
|
+
await release();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async acquireLock(): Promise<() => Promise<void>> {
|
|
172
|
+
const lockPath = `${this.path}.lock`;
|
|
173
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
174
|
+
const token = randomUUID();
|
|
175
|
+
const ownerPath = join(lockPath, `owner-${token}`);
|
|
176
|
+
const deadline = Date.now() + CONFIG_LOCK_WAIT_MS;
|
|
177
|
+
|
|
178
|
+
while (true) {
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
181
|
+
try {
|
|
182
|
+
await writeFile(ownerPath, token, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await rm(ownerPath, { force: true }).catch(() => undefined);
|
|
185
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
return async () => {
|
|
189
|
+
if ((await readFile(ownerPath, "utf8").catch(() => undefined)) !== token) return;
|
|
190
|
+
try {
|
|
191
|
+
await rm(ownerPath);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
194
|
+
if (code === "ENOENT") return;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
await rmdir(lockPath).catch((error) => {
|
|
198
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
199
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error;
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (!error || typeof error !== "object" || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
const info = await lstat(lockPath).catch(() => undefined);
|
|
207
|
+
if (!info || Date.now() - info.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
208
|
+
if (!info) continue;
|
|
209
|
+
const ownerName = await this.readOwnerName(lockPath);
|
|
210
|
+
const currentInfo = await lstat(lockPath).catch(() => undefined);
|
|
211
|
+
const currentOwnerName = await this.readOwnerName(lockPath);
|
|
212
|
+
if (
|
|
213
|
+
!currentInfo ||
|
|
214
|
+
currentInfo.dev !== info.dev ||
|
|
215
|
+
currentInfo.ino !== info.ino ||
|
|
216
|
+
currentInfo.ctimeMs !== info.ctimeMs ||
|
|
217
|
+
currentOwnerName !== ownerName
|
|
218
|
+
) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// Remove only observed owner's unique entry. Only the process that
|
|
222
|
+
// removes that entry may remove lockPath; this prevents a stale
|
|
223
|
+
// reclaimer from deleting a replacement created in between.
|
|
224
|
+
if (!ownerName) {
|
|
225
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for config lock: ${this.path}`);
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, CONFIG_LOCK_RETRY_MS));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await rm(join(lockPath, ownerName));
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
233
|
+
if (code === "ENOENT") continue;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
await rmdir(lockPath).catch((error) => {
|
|
237
|
+
const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined;
|
|
238
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error;
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for config lock: ${this.path}`);
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, CONFIG_LOCK_RETRY_MS));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async readOwnerName(lockPath: string): Promise<string | undefined> {
|
|
249
|
+
const entries = await readdir(lockPath, { withFileTypes: true }).catch(() => []);
|
|
250
|
+
const owners = entries.filter((entry) => entry.isFile() && entry.name.startsWith("owner-"));
|
|
251
|
+
return owners.length === 1 ? owners[0]?.name : undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
utimes,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { isBtwPayload, type BtwPayload } from "./core.ts";
|
|
17
|
+
import { isMergeRequest, MERGE_REQUEST_FILE, type MergeRequest } from "./merge.ts";
|
|
18
|
+
|
|
19
|
+
const PAYLOAD_FILE = "payload.json";
|
|
20
|
+
const LAUNCH_PREFIX = "launch-";
|
|
21
|
+
const MAX_MAILBOX_FILE_BYTES = 128 * 1024;
|
|
22
|
+
export const MAX_PAYLOAD_FILE_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_STALE_CONTEXT_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function currentUid(): number | undefined {
|
|
27
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertOwnedByCurrentUser(uid: number, path: string): void {
|
|
31
|
+
const expectedUid = currentUid();
|
|
32
|
+
if (expectedUid !== undefined && uid !== expectedUid) {
|
|
33
|
+
throw new Error(`Refusing /btw context path not owned by the current user: ${path}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertPrivateMode(mode: number, path: string): void {
|
|
38
|
+
if (process.platform !== "win32" && (mode & 0o077) !== 0) {
|
|
39
|
+
throw new Error(`Refusing /btw context path with group or other permissions: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isInside(root: string, candidate: string): boolean {
|
|
44
|
+
const child = relative(root, candidate);
|
|
45
|
+
return child !== "" && !child.startsWith("..") && !isAbsolute(child);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isMissing(error: unknown): boolean {
|
|
49
|
+
return !!error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function defaultContextRoot(): string {
|
|
53
|
+
const uid = currentUid();
|
|
54
|
+
return join(tmpdir(), uid === undefined ? "pi-herdr-btw" : `pi-herdr-btw-${uid}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ContextStore {
|
|
58
|
+
readonly root: string;
|
|
59
|
+
private canonicalRoot: string | undefined;
|
|
60
|
+
|
|
61
|
+
constructor(root = defaultContextRoot()) {
|
|
62
|
+
this.root = resolve(root);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async create(payload: BtwPayload): Promise<string> {
|
|
66
|
+
const root = await this.ensureRoot();
|
|
67
|
+
const serialized = `${JSON.stringify(payload)}\n`;
|
|
68
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_PAYLOAD_FILE_BYTES) {
|
|
69
|
+
throw new Error("Refusing oversized /btw context payload");
|
|
70
|
+
}
|
|
71
|
+
const launchDir = await mkdtemp(join(root, LAUNCH_PREFIX));
|
|
72
|
+
try {
|
|
73
|
+
await chmod(launchDir, 0o700);
|
|
74
|
+
|
|
75
|
+
const payloadPath = join(launchDir, PAYLOAD_FILE);
|
|
76
|
+
await writeFile(payloadPath, serialized, {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
flag: "wx",
|
|
79
|
+
mode: 0o600,
|
|
80
|
+
});
|
|
81
|
+
await chmod(payloadPath, 0o600);
|
|
82
|
+
return payloadPath;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await rm(launchDir, { recursive: true, force: true }).catch(() => undefined);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async read(payloadPath: string): Promise<BtwPayload> {
|
|
90
|
+
const canonicalPath = await this.validateLaunchFile(payloadPath, PAYLOAD_FILE);
|
|
91
|
+
if (!canonicalPath) throw new Error(`Missing /btw context payload: ${payloadPath}`);
|
|
92
|
+
const parsed: unknown = JSON.parse(await readFile(canonicalPath, "utf8"));
|
|
93
|
+
if (!isBtwPayload(parsed)) {
|
|
94
|
+
throw new Error("Invalid or unsupported /btw context payload");
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** List payload paths for every launch directory currently in the private root. */
|
|
100
|
+
async listLaunchPayloadPaths(): Promise<string[]> {
|
|
101
|
+
const root = await this.ensureRoot();
|
|
102
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
103
|
+
return entries
|
|
104
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(LAUNCH_PREFIX))
|
|
105
|
+
.map((entry) => join(root, entry.name, PAYLOAD_FILE));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async writeMergeRequest(payloadPath: string, request: MergeRequest): Promise<void> {
|
|
109
|
+
if (!isMergeRequest(request)) throw new Error("Invalid /btw merge request");
|
|
110
|
+
await this.writeLaunchFile(payloadPath, MERGE_REQUEST_FILE, request);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async readMergeRequest(payloadPath: string): Promise<unknown> {
|
|
114
|
+
return this.readLaunchFile(payloadPath, MERGE_REQUEST_FILE);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Keep a launch with a request until its parent consumes it. */
|
|
118
|
+
async removeIfNoPendingMerge(payloadPath: string): Promise<boolean> {
|
|
119
|
+
if ((await this.readMergeRequest(payloadPath)) !== undefined) return false;
|
|
120
|
+
await this.remove(payloadPath);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async remove(payloadPath: string): Promise<void> {
|
|
125
|
+
const launchDir = await this.validateLaunchDir(payloadPath, true);
|
|
126
|
+
if (launchDir) await rm(launchDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Refresh launch liveness without modifying mailbox contents. */
|
|
130
|
+
async touch(payloadPath: string): Promise<void> {
|
|
131
|
+
const launchDir = await this.validateLaunchDir(payloadPath, true);
|
|
132
|
+
if (launchDir) {
|
|
133
|
+
const now = new Date();
|
|
134
|
+
await utimes(launchDir, now, now);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async removeStale(
|
|
139
|
+
maxAgeMs = DEFAULT_STALE_CONTEXT_MS,
|
|
140
|
+
now = Date.now(),
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const root = await this.ensureRoot();
|
|
143
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
144
|
+
// One unsafe or foreign entry must not abort cleanup of the others.
|
|
145
|
+
await Promise.allSettled(
|
|
146
|
+
entries.map(async (entry) => {
|
|
147
|
+
if (!entry.name.startsWith(LAUNCH_PREFIX) || !entry.isDirectory()) return;
|
|
148
|
+
const launchDir = join(root, entry.name);
|
|
149
|
+
const info = await lstat(launchDir).catch(() => undefined);
|
|
150
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) return;
|
|
151
|
+
assertOwnedByCurrentUser(info.uid, launchDir);
|
|
152
|
+
if (info.mtimeMs < now - maxAgeMs) {
|
|
153
|
+
// A heartbeat may have refreshed this launch after the first stat.
|
|
154
|
+
const current = await lstat(launchDir).catch(() => undefined);
|
|
155
|
+
if (
|
|
156
|
+
!current?.isDirectory() ||
|
|
157
|
+
current.isSymbolicLink() ||
|
|
158
|
+
current.dev !== info.dev ||
|
|
159
|
+
current.ino !== info.ino ||
|
|
160
|
+
current.ctimeMs !== info.ctimeMs ||
|
|
161
|
+
current.mtimeMs !== info.mtimeMs
|
|
162
|
+
) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await rm(launchDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async ensureRoot(): Promise<string> {
|
|
172
|
+
if (this.canonicalRoot) return this.canonicalRoot;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await mkdir(this.root, { mode: 0o700 });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!error || typeof error !== "object" || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const info = await lstat(this.root);
|
|
183
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
184
|
+
throw new Error(`Refusing unsafe /btw context root: ${this.root}`);
|
|
185
|
+
}
|
|
186
|
+
assertOwnedByCurrentUser(info.uid, this.root);
|
|
187
|
+
await chmod(this.root, 0o700);
|
|
188
|
+
const canonicalRoot = await realpath(this.root);
|
|
189
|
+
const canonicalInfo = await lstat(canonicalRoot);
|
|
190
|
+
assertOwnedByCurrentUser(canonicalInfo.uid, canonicalRoot);
|
|
191
|
+
assertPrivateMode(canonicalInfo.mode, canonicalRoot);
|
|
192
|
+
this.canonicalRoot = canonicalRoot;
|
|
193
|
+
return canonicalRoot;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async writeLaunchFile(payloadPath: string, fileName: string, data: unknown): Promise<void> {
|
|
197
|
+
const launchDir = await this.validateLaunchDir(payloadPath, false);
|
|
198
|
+
if (!launchDir) throw new Error(`Missing /btw launch directory: ${payloadPath}`);
|
|
199
|
+
const targetPath = join(launchDir, fileName);
|
|
200
|
+
const temporaryPath = join(launchDir, `.${fileName}.${process.pid}.${Date.now()}.tmp`);
|
|
201
|
+
try {
|
|
202
|
+
await writeFile(temporaryPath, `${JSON.stringify(data)}\n`, {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
flag: "wx",
|
|
205
|
+
mode: 0o600,
|
|
206
|
+
});
|
|
207
|
+
await chmod(temporaryPath, 0o600);
|
|
208
|
+
await rename(temporaryPath, targetPath);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async readLaunchFile(payloadPath: string, fileName: string): Promise<unknown> {
|
|
216
|
+
const canonicalPath = await this.validateLaunchFile(payloadPath, fileName);
|
|
217
|
+
if (!canonicalPath) return undefined;
|
|
218
|
+
return JSON.parse(await readFile(canonicalPath, "utf8")) as unknown;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async validateLaunchFile(
|
|
222
|
+
payloadPath: string,
|
|
223
|
+
fileName: string,
|
|
224
|
+
): Promise<string | undefined> {
|
|
225
|
+
const launchDir = await this.validateLaunchDir(payloadPath, false);
|
|
226
|
+
if (!launchDir) throw new Error(`Missing /btw context payload: ${payloadPath}`);
|
|
227
|
+
|
|
228
|
+
const candidate = join(launchDir, fileName);
|
|
229
|
+
let info;
|
|
230
|
+
try {
|
|
231
|
+
info = await lstat(candidate);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (fileName !== PAYLOAD_FILE && isMissing(error)) return undefined;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
237
|
+
throw new Error(`Refusing unsafe /btw context payload: ${candidate}`);
|
|
238
|
+
}
|
|
239
|
+
assertOwnedByCurrentUser(info.uid, candidate);
|
|
240
|
+
assertPrivateMode(info.mode, candidate);
|
|
241
|
+
const maxBytes = fileName === PAYLOAD_FILE ? MAX_PAYLOAD_FILE_BYTES : MAX_MAILBOX_FILE_BYTES;
|
|
242
|
+
if (info.size > maxBytes) {
|
|
243
|
+
throw new Error(`Refusing oversized /btw mailbox file: ${candidate}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const canonicalPath = await realpath(candidate);
|
|
247
|
+
const root = await this.ensureRoot();
|
|
248
|
+
if (!isInside(root, canonicalPath)) {
|
|
249
|
+
throw new Error(`Refusing /btw context payload outside the private root: ${payloadPath}`);
|
|
250
|
+
}
|
|
251
|
+
return canonicalPath;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async validateLaunchDir(
|
|
255
|
+
payloadPath: string,
|
|
256
|
+
allowMissing: boolean,
|
|
257
|
+
): Promise<string | undefined> {
|
|
258
|
+
const root = await this.ensureRoot();
|
|
259
|
+
const absolutePayload = resolve(payloadPath);
|
|
260
|
+
const launchDir = dirname(absolutePayload);
|
|
261
|
+
if (basename(absolutePayload) !== PAYLOAD_FILE || !basename(launchDir).startsWith(LAUNCH_PREFIX)) {
|
|
262
|
+
throw new Error(`Refusing invalid /btw context payload path: ${payloadPath}`);
|
|
263
|
+
}
|
|
264
|
+
if (!isInside(root, launchDir)) {
|
|
265
|
+
throw new Error(`Refusing /btw context payload outside the private root: ${payloadPath}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let info;
|
|
269
|
+
try {
|
|
270
|
+
info = await lstat(launchDir);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (allowMissing && isMissing(error)) return undefined;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
276
|
+
throw new Error(`Refusing unsafe /btw launch directory: ${launchDir}`);
|
|
277
|
+
}
|
|
278
|
+
assertOwnedByCurrentUser(info.uid, launchDir);
|
|
279
|
+
assertPrivateMode(info.mode, launchDir);
|
|
280
|
+
|
|
281
|
+
const canonicalLaunchDir = await realpath(launchDir);
|
|
282
|
+
if (!isInside(root, canonicalLaunchDir)) {
|
|
283
|
+
throw new Error(`Refusing /btw launch directory outside the private root: ${launchDir}`);
|
|
284
|
+
}
|
|
285
|
+
return canonicalLaunchDir;
|
|
286
|
+
}
|
|
287
|
+
}
|