@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.71
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 +33 -22
- package/src/api.test.ts +891 -50
- package/src/api.ts +849 -72
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +77 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +459 -69
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +186 -0
- package/src/diff-watcher.ts +102 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +39 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +255 -28
- package/src/review.ts +164 -62
- package/src/server.test.ts +381 -39
- package/src/server.ts +322 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +261 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +174 -28
- package/src/validate.ts +226 -16
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr adapter scratch, kept out of the core session record: the tab cueloop
|
|
3
|
+
* opened to render each review, keyed by session id. One JSON map, atomic write.
|
|
4
|
+
* The core `Thread` never mentions herdr; this side-store is the whole
|
|
5
|
+
* leak boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
import { herdrTabsPath } from "./paths";
|
|
11
|
+
|
|
12
|
+
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
13
|
+
export interface HerdrTabHandle {
|
|
14
|
+
tabId: string;
|
|
15
|
+
paneId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HerdrTabHandleSchema = v.object({
|
|
19
|
+
tabId: v.string(),
|
|
20
|
+
paneId: v.string(),
|
|
21
|
+
});
|
|
22
|
+
const HerdrTabsSchema = v.record(v.string(), v.unknown());
|
|
23
|
+
|
|
24
|
+
export class HerdrTabStore {
|
|
25
|
+
private tabs = new Map<string, HerdrTabHandle>();
|
|
26
|
+
private readonly path: string;
|
|
27
|
+
|
|
28
|
+
constructor(home: string) {
|
|
29
|
+
this.path = herdrTabsPath(home);
|
|
30
|
+
this.load();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private load(): void {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
|
|
36
|
+
|
|
37
|
+
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
38
|
+
const handle = v.safeParse(HerdrTabHandleSchema, value);
|
|
39
|
+
|
|
40
|
+
if (handle.success) this.tabs.set(sessionId, handle.output);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private persist(): void {
|
|
48
|
+
const tempPath = this.path + ".tmp";
|
|
49
|
+
|
|
50
|
+
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
|
|
51
|
+
renameSync(tempPath, this.path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get(sessionId: string): HerdrTabHandle | null {
|
|
55
|
+
return this.tabs.get(sessionId) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
set(sessionId: string, handle: HerdrTabHandle): void {
|
|
59
|
+
this.tabs.set(sessionId, handle);
|
|
60
|
+
this.persist();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
delete(sessionId: string): void {
|
|
64
|
+
if (this.tabs.delete(sessionId)) this.persist();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
3
|
+
|
|
4
|
+
function deferred<T>() {
|
|
5
|
+
let resolve!: (value: T) => void;
|
|
6
|
+
let reject!: (cause: unknown) => void;
|
|
7
|
+
const promise = new Promise<T>((res, rej) => {
|
|
8
|
+
resolve = res;
|
|
9
|
+
reject = rej;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return { promise, resolve, reject };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("raceAbort", () => {
|
|
16
|
+
test("without a signal the promise passes straight through", async () => {
|
|
17
|
+
expect(await raceAbort(Promise.resolve("value"), undefined)).toBe("value");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves to the value when the promise settles before any abort", async () => {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
|
|
23
|
+
expect(await raceAbort(Promise.resolve(7), controller.signal)).toBe(7);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an already-aborted signal returns ABORTED without waiting", async () => {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
|
|
29
|
+
controller.abort();
|
|
30
|
+
|
|
31
|
+
expect(await raceAbort(new Promise<never>(() => {}), controller.signal)).toBe(ABORTED);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a signal that fires during the wait returns ABORTED", async () => {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const pending = deferred<number>();
|
|
37
|
+
const raced = raceAbort(pending.promise, controller.signal);
|
|
38
|
+
|
|
39
|
+
controller.abort();
|
|
40
|
+
|
|
41
|
+
expect(await raced).toBe(ABORTED);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a rejection propagates the original error unchanged", async () => {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const original = new Error("daemon socket closed");
|
|
47
|
+
let caught: unknown;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await raceAbort(Promise.reject(original), controller.signal);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
caught = error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(caught).toBe(original);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a rejection that lands after an abort is swallowed, not surfaced", async () => {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const pending = deferred<number>();
|
|
61
|
+
const raced = raceAbort(pending.promise, controller.signal);
|
|
62
|
+
|
|
63
|
+
controller.abort();
|
|
64
|
+
const result = await raced;
|
|
65
|
+
|
|
66
|
+
pending.reject(new Error("late daemon error"));
|
|
67
|
+
await Bun.sleep(10);
|
|
68
|
+
|
|
69
|
+
expect(result).toBe(ABORTED);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("pollUntilResolved", () => {
|
|
74
|
+
test("returns the first non-null attempt", async () => {
|
|
75
|
+
expect(await pollUntilResolved(() => Promise.resolve("done"), undefined)).toBe("done");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("keeps polling past null attempts until one resolves", async () => {
|
|
79
|
+
let calls = 0;
|
|
80
|
+
const attempt = () => {
|
|
81
|
+
calls++;
|
|
82
|
+
|
|
83
|
+
return Promise.resolve(calls < 3 ? null : "third");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
expect(await pollUntilResolved(attempt, undefined)).toBe("third");
|
|
87
|
+
expect(calls).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("an already-aborted signal returns null before any attempt", async () => {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
let calls = 0;
|
|
93
|
+
|
|
94
|
+
controller.abort();
|
|
95
|
+
const result = await pollUntilResolved(() => {
|
|
96
|
+
calls++;
|
|
97
|
+
|
|
98
|
+
return Promise.resolve("value");
|
|
99
|
+
}, controller.signal);
|
|
100
|
+
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
expect(calls).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a signal that fires during a chunk returns null", async () => {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const pending = deferred<string | null>();
|
|
108
|
+
const polling = pollUntilResolved(() => pending.promise, controller.signal);
|
|
109
|
+
|
|
110
|
+
controller.abort();
|
|
111
|
+
|
|
112
|
+
expect(await polling).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a rejected attempt propagates the original error", async () => {
|
|
116
|
+
const original = new Error("attempt failed");
|
|
117
|
+
let caught: unknown;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await pollUntilResolved(() => Promise.reject(original), undefined);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
caught = error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(caught).toBe(original);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Cause, Effect, Exit } from "effect";
|
|
2
|
+
|
|
3
|
+
export const ABORTED: unique symbol = Symbol("aborted");
|
|
4
|
+
|
|
5
|
+
const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
|
|
6
|
+
Effect.callback<void>((resume) => {
|
|
7
|
+
if (signal.aborted) {
|
|
8
|
+
resume(Effect.void);
|
|
9
|
+
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const onAbort = () => resume(Effect.void);
|
|
13
|
+
|
|
14
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
15
|
+
|
|
16
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const fromSettledPromise = <T>(start: () => Promise<T>): Effect.Effect<T, unknown> =>
|
|
20
|
+
Effect.callback<T, unknown>((resume) => {
|
|
21
|
+
start().then(
|
|
22
|
+
(value) => resume(Effect.succeed(value)),
|
|
23
|
+
(cause: unknown) => resume(Effect.fail(cause)),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function runToPromise<T>(effect: Effect.Effect<T, unknown>): Promise<T> {
|
|
28
|
+
const exit = await Effect.runPromiseExit(effect);
|
|
29
|
+
|
|
30
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
31
|
+
|
|
32
|
+
throw Cause.squash(exit.cause);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function raceAbort<T>(
|
|
36
|
+
promise: Promise<T>,
|
|
37
|
+
signal: AbortSignal | undefined,
|
|
38
|
+
): Promise<T | typeof ABORTED> {
|
|
39
|
+
if (!signal) return promise;
|
|
40
|
+
if (signal.aborted) {
|
|
41
|
+
promise.catch(() => {});
|
|
42
|
+
|
|
43
|
+
return Promise.resolve(ABORTED);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return runToPromise(
|
|
47
|
+
Effect.raceFirst(
|
|
48
|
+
fromSettledPromise(() => promise),
|
|
49
|
+
whenSignalAborts(signal).pipe(Effect.as<T | typeof ABORTED>(ABORTED)),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function pollUntilResolved<T>(
|
|
55
|
+
attempt: () => Promise<T | null>,
|
|
56
|
+
signal: AbortSignal | undefined,
|
|
57
|
+
): Promise<T | null> {
|
|
58
|
+
if (signal?.aborted) return Promise.resolve(null);
|
|
59
|
+
const loop = Effect.repeat(fromSettledPromise(attempt), {
|
|
60
|
+
until: (result): result is T => result !== null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!signal) return runToPromise(loop);
|
|
64
|
+
|
|
65
|
+
return runToPromise(Effect.raceFirst(loop, whenSignalAborts(signal).pipe(Effect.as(null))));
|
|
66
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { DaemonServer } from "./server";
|
|
4
4
|
|
|
5
|
-
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
5
|
+
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
6
|
+
? Number(process.env.CUELOOP_IDLE_EXIT_MS)
|
|
7
|
+
: undefined;
|
|
6
8
|
const server = new DaemonServer({ idleExitMs });
|
|
7
9
|
const path = server.start();
|
|
10
|
+
|
|
8
11
|
if (path === null) {
|
|
9
12
|
// another daemon already owns this home; the client will attach to it
|
|
10
13
|
console.log("cueloop daemon already running for this home");
|
package/src/paths.ts
CHANGED
|
@@ -18,6 +18,35 @@ export function sessionsDir(home = cueloopHome()): string {
|
|
|
18
18
|
return join(home, "sessions");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Thread files live here, one JSONL per thread, under a per-project bucket. */
|
|
22
|
+
export function threadsDir(home = cueloopHome()): string {
|
|
23
|
+
return join(home, "threads");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A git commit is lowercase hex; anything else is not a real root commit. */
|
|
27
|
+
const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
|
|
31
|
+
* A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
|
|
32
|
+
* is refused to the standalone bucket, so a bucket name can never escape the threads directory.
|
|
33
|
+
*/
|
|
34
|
+
export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
|
|
35
|
+
const bucket =
|
|
36
|
+
rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
|
|
37
|
+
|
|
38
|
+
return join(threadsDir(home), bucket);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
|
|
42
|
+
export function migratedSessionsDir(home = cueloopHome()): string {
|
|
43
|
+
return join(home, "sessions.migrated");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reportsDir(home = cueloopHome()): string {
|
|
47
|
+
return join(home, "reports");
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
export function pidPath(home = cueloopHome()): string {
|
|
22
51
|
return join(home, "cueloop.pid");
|
|
23
52
|
}
|
|
@@ -25,3 +54,13 @@ export function pidPath(home = cueloopHome()): string {
|
|
|
25
54
|
export function lockPath(home = cueloopHome()): string {
|
|
26
55
|
return join(home, "cueloop.lock");
|
|
27
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
|
|
59
|
+
export function ownerTokenPath(home = cueloopHome()): string {
|
|
60
|
+
return join(home, "owner.token");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
64
|
+
export function herdrTabsPath(home = cueloopHome()): string {
|
|
65
|
+
return join(home, "herdr-tabs.json");
|
|
66
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Read-only repo introspection: the tracked file tree and safe, bounded file reads. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
8
|
+
|
|
9
|
+
function git(args: string[], cwd: string): void {
|
|
10
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
|
|
11
|
+
|
|
12
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let repo: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
repo = mkdtempSync(join(tmpdir(), "cueloop-project-files-"));
|
|
19
|
+
git(["init", "-q", "-b", "main"], repo);
|
|
20
|
+
git(["config", "user.email", "t@t"], repo);
|
|
21
|
+
git(["config", "user.name", "t"], repo);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(repo, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("listProjectFiles", () => {
|
|
29
|
+
test("returns the tracked files sorted ascending", async () => {
|
|
30
|
+
// Given a repo whose files are committed out of alphabetical order
|
|
31
|
+
writeFileSync(join(repo, "z.ts"), "export const z = 1;\n");
|
|
32
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
33
|
+
writeFileSync(join(repo, "m.ts"), "export const m = 1;\n");
|
|
34
|
+
git(["add", "."], repo);
|
|
35
|
+
git(["commit", "-qm", "init"], repo);
|
|
36
|
+
|
|
37
|
+
// When the tracked files are listed
|
|
38
|
+
const files = await listProjectFiles(repo);
|
|
39
|
+
|
|
40
|
+
// Then every tracked path comes back in ascending order
|
|
41
|
+
expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns [] for a directory that is not a git repo", async () => {
|
|
45
|
+
// Given a plain directory with a file but no git repo
|
|
46
|
+
const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
|
|
47
|
+
|
|
48
|
+
writeFileSync(join(plain, "a.ts"), "export const a = 1;\n");
|
|
49
|
+
try {
|
|
50
|
+
// When the tracked files are listed
|
|
51
|
+
const files = await listProjectFiles(plain);
|
|
52
|
+
|
|
53
|
+
// Then nothing comes back and no error is thrown
|
|
54
|
+
expect(files).toEqual([]);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(plain, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns [] when there is no repoRoot", async () => {
|
|
61
|
+
expect(await listProjectFiles(undefined)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("readProjectFile", () => {
|
|
66
|
+
test("reads a repo-relative file as UTF-8", async () => {
|
|
67
|
+
// Given a committed file with known contents
|
|
68
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
69
|
+
git(["add", "."], repo);
|
|
70
|
+
git(["commit", "-qm", "init"], repo);
|
|
71
|
+
|
|
72
|
+
// When it is read by its repo-relative path
|
|
73
|
+
const contents = await readProjectFile(repo, "a.ts");
|
|
74
|
+
|
|
75
|
+
// Then its exact bytes come back
|
|
76
|
+
expect(contents).toBe("export const a = 1;\n");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns null for a path that escapes the repo via ../", async () => {
|
|
80
|
+
// Given a secret file just outside the repo root
|
|
81
|
+
const secret = join(repo, "..", "outside-secret.txt");
|
|
82
|
+
|
|
83
|
+
writeFileSync(secret, "top secret\n");
|
|
84
|
+
try {
|
|
85
|
+
// When a traversal path tries to reach it
|
|
86
|
+
const contents = await readProjectFile(repo, "../outside-secret.txt");
|
|
87
|
+
|
|
88
|
+
// Then the read is refused
|
|
89
|
+
expect(contents).toBeNull();
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(secret, { force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns null for a missing file", async () => {
|
|
96
|
+
expect(await readProjectFile(repo, "nope.ts")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns null for a directory (not a regular file)", async () => {
|
|
100
|
+
// Given a directory inside the repo
|
|
101
|
+
mkdirSync(join(repo, "src"));
|
|
102
|
+
|
|
103
|
+
// When the directory path is read
|
|
104
|
+
const contents = await readProjectFile(repo, "src");
|
|
105
|
+
|
|
106
|
+
// Then it is refused
|
|
107
|
+
expect(contents).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns null for a file larger than the cap", async () => {
|
|
111
|
+
// Given a file just over one megabyte
|
|
112
|
+
writeFileSync(join(repo, "big.bin"), "x".repeat(1024 * 1024 + 1));
|
|
113
|
+
|
|
114
|
+
// When it is read
|
|
115
|
+
const contents = await readProjectFile(repo, "big.bin");
|
|
116
|
+
|
|
117
|
+
// Then it is refused for exceeding the size cap
|
|
118
|
+
expect(contents).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns null when there is no repoRoot", async () => {
|
|
122
|
+
expect(await readProjectFile(undefined, "a.ts")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Read-only repo introspection for the Project panel: the tracked file tree and a file's contents. */
|
|
2
|
+
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Ceiling on the returned tree; a pathological repo cannot flood the panel or the wire. */
|
|
7
|
+
const MAX_FILES = 5000;
|
|
8
|
+
/** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
|
|
9
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/** Tracked, repo-relative paths from `git ls-files`, sorted ascending and capped; [] on any failure or non-repo. */
|
|
12
|
+
export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
|
|
13
|
+
if (repoRoot === undefined || repoRoot.length === 0) return [];
|
|
14
|
+
try {
|
|
15
|
+
const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z"], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "ignore",
|
|
18
|
+
});
|
|
19
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
20
|
+
|
|
21
|
+
if ((await gitProcess.exited) !== 0) return [];
|
|
22
|
+
const paths = stdout.split("\0").filter((path) => path.length > 0);
|
|
23
|
+
|
|
24
|
+
return paths.toSorted().slice(0, MAX_FILES);
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `candidate` resolves outside `root` (path traversal) or is `root` itself. */
|
|
31
|
+
function escapesRoot(root: string, candidate: string): boolean {
|
|
32
|
+
const rel = relative(root, candidate);
|
|
33
|
+
|
|
34
|
+
return rel.length === 0 || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** UTF-8 contents of `repoRoot/path`, or null: no repo, traversal escape, not a regular file, oversized, or any error. */
|
|
38
|
+
export async function readProjectFile(
|
|
39
|
+
repoRoot: string | undefined,
|
|
40
|
+
path: string,
|
|
41
|
+
): Promise<string | null> {
|
|
42
|
+
if (repoRoot === undefined || repoRoot.length === 0) return null;
|
|
43
|
+
const root = resolve(repoRoot);
|
|
44
|
+
const target = resolve(root, path);
|
|
45
|
+
|
|
46
|
+
if (escapesRoot(root, target)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const stats = await stat(target);
|
|
49
|
+
|
|
50
|
+
if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return null;
|
|
51
|
+
|
|
52
|
+
return await Bun.file(target).text();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wire protocol
|
|
2
|
+
* Wire protocol: newline-delimited JSON over the unix socket.
|
|
3
3
|
* Requests: { id, method, params } Responses: { id, result } | { id, error }
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as v from "valibot";
|
|
8
|
+
|
|
7
9
|
export interface Request {
|
|
8
10
|
id: number;
|
|
9
11
|
method: string;
|
|
@@ -19,22 +21,92 @@ export interface Response {
|
|
|
19
21
|
export interface EventFrame {
|
|
20
22
|
event: string;
|
|
21
23
|
sessionId: string;
|
|
24
|
+
/** The history entry the change appended, when it appended one. */
|
|
25
|
+
entryId?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export type Frame = Response | EventFrame;
|
|
25
29
|
|
|
30
|
+
const RequestSchema = v.object({
|
|
31
|
+
id: v.number(),
|
|
32
|
+
method: v.string(),
|
|
33
|
+
params: v.optional(v.unknown()),
|
|
34
|
+
});
|
|
35
|
+
const ResponseSchema = v.object({
|
|
36
|
+
id: v.number(),
|
|
37
|
+
result: v.optional(v.unknown()),
|
|
38
|
+
error: v.optional(v.object({ code: v.string(), message: v.string() })),
|
|
39
|
+
});
|
|
40
|
+
const EventFrameSchema = v.object({
|
|
41
|
+
event: v.string(),
|
|
42
|
+
sessionId: v.string(),
|
|
43
|
+
entryId: v.optional(v.string()),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function parseRequestFrame(line: string): Request {
|
|
47
|
+
return v.parse(RequestSchema, JSON.parse(line));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseInboundFrame(line: string): Frame {
|
|
51
|
+
return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
55
|
export class LineBuffer {
|
|
28
|
-
private
|
|
56
|
+
private buffered = "";
|
|
29
57
|
|
|
30
58
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
59
|
+
this.buffered += chunk;
|
|
32
60
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
const newlineIndex = this.buffered.indexOf("\n");
|
|
62
|
+
|
|
63
|
+
if (newlineIndex === -1) return;
|
|
64
|
+
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
65
|
+
|
|
66
|
+
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
67
|
if (line) onLine(line);
|
|
38
68
|
}
|
|
39
69
|
}
|
|
40
70
|
}
|
|
71
|
+
|
|
72
|
+
/** The surface both Bun.listen and Bun.connect sockets satisfy. */
|
|
73
|
+
interface WritableSocket {
|
|
74
|
+
write(data: Buffer): number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Outbound writes honoring socket backpressure. A frame larger than the
|
|
79
|
+
* kernel buffer is written partially by the socket; the unwritten tail must
|
|
80
|
+
* wait for the drain callback or the frame truncates mid-line and the NDJSON
|
|
81
|
+
* stream corrupts for good. Frames never reorder: while a tail is pending,
|
|
82
|
+
* new writes append behind it.
|
|
83
|
+
*/
|
|
84
|
+
export class BackpressureWriter {
|
|
85
|
+
private pending: Buffer | null = null;
|
|
86
|
+
|
|
87
|
+
constructor(private socket: WritableSocket) {}
|
|
88
|
+
|
|
89
|
+
write(data: string): void {
|
|
90
|
+
const bytes = Buffer.from(data, "utf8");
|
|
91
|
+
|
|
92
|
+
if (this.pending) {
|
|
93
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
94
|
+
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const written = Math.max(0, this.socket.write(bytes));
|
|
98
|
+
|
|
99
|
+
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Call from the socket's drain handler. */
|
|
103
|
+
drain(): void {
|
|
104
|
+
if (!this.pending) return;
|
|
105
|
+
const tail = this.pending;
|
|
106
|
+
|
|
107
|
+
this.pending = null;
|
|
108
|
+
const written = Math.max(0, this.socket.write(tail));
|
|
109
|
+
|
|
110
|
+
if (written < tail.length) this.pending = tail.subarray(written);
|
|
111
|
+
}
|
|
112
|
+
}
|