diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0e412..3768c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,68 @@ All notable changes to **basou** are recorded here. The project follows - **`@basou/sdk` tells such an entry apart by the error's class instead of its message.** `getSession`, `readEvents` and `streamEvents` return `null`, an empty list and nothing for it, as before. +- **A session's `events.jsonl` or `session.yaml` that is a symlink, whatever + it points to, or anything but a regular file is not followed.** basou read + through such a file, reading a log or a `session.yaml` from outside the + store, and a FIFO there made `basou verify`, `basou board measure` and every + command that reads the session's events wait without end. basou never + creates either form. Nothing is now read through it or waits on it, and + nothing is appended or written through it: + - `basou verify` reports the session `tampered` with a new `reason`, + `events_not_a_file` or `yaml_not_a_file`, and `event_count` `0`. It used + to follow the file and report what it found behind it — `verified`, exit + `0`, for an intact log behind a symlink — or wait on a FIFO. A + `session.yaml` that is a regular file is still read beside an + `events.jsonl` that is not, so a newer basou's session is `unsupported` + as before, and `session_yaml_invalid` is reported as on any row. `session + rechain` skips such a session with the same reasons and exits non-zero, + and an in-place re-import skips it as a prior whose chain fails + verification. + - The commands that read the session by its id stop with ` of session + is not a regular file; a symlink or anything else there is not + followed`: `session show`, `approval show`, `approve` and `reject` (which + leave the approval pending), `session note`, `decision record`, a `task` + command attaching to the session, and `exec` and `run` for the session + they started. `approve` and `reject` went on when they could not read the + `session.yaml`; they stop for one that is not a regular file. `basou + view`'s session page answers `404` with the message for a `session.yaml`, + and shows the session without its events for an `events.jsonl`. + - The commands that list or summarize sessions leave out a session whose + `session.yaml` is such, with a warning naming its full id + (`session_yaml_not_a_file`); one whose `events.jsonl` is such is kept, its + events unreadable (`events_jsonl_unreadable`). `import` warns that a + session whose `session.yaml` is such may be imported again, and `task + refresh-linkage` keeps a link to it. + - A directory where `events.jsonl` should be, which these commands reported + as a failure to read it, is one of these cases: `basou verify` reports the + session `tampered` instead of stopping without output, and `report + generate` counts it `tampered` instead of leaving it out of the integrity + tally. +- **`docs/spec/compatibility.md` bounds a command coming to refuse a layout + basou never creates,** the one way an invocation moves from `0` to a + non-zero exit within `1.x`, as this change does: nothing changes on a + layout basou creates, the refusal names what it refuses, and the changelog + lists it. A `basou verify` row that passed may come to fail as `tampered` + under that bound, beside the existing rule that a new `reason` only refines + a status. +- **`basou board measure` raises the method of `integrity`, `trail`, + `review_gaps`, `freshness` and `effort` to 2,** as each counts such a + session differently: `integrity` counts it as `basou verify` judges it, and + each other section is not measured when a file it reads is such — `trail`, + `review_gaps` and `effort` read the `events.jsonl`, and `review_gaps`, + `freshness` and `effort` the `session.yaml`. +- **`@basou/core` exports `SessionFileNotAFileError`, `lstatSessionFile` and + the types `SessionFileName` and `SessionFileKind`.** The event readers, + `readSessionYaml` and the chained append throw the error, which carries the + `sessionId`, the `file` and the `kind` (`symlink` or `not_a_file`). + `SessionSkipReason` gains `session_yaml_not_a_file`, and `RechainResult` + gains `events_not_a_file` and `yaml_not_a_file`. +- **`@basou/sdk` does not follow such a file either.** `listSessions` and + `getSession` leave out a session whose `session.yaml` is such, as one whose + `session.yaml` cannot be read; `readEvents` and `streamEvents` return no + events for an `events.jsonl` that is such, where they read through it or + waited on it, and report it to `onDiagnostic` as an unreadable log + (`events_jsonl_unreadable`), as `stats` and the renderers do. ## 0.69.0 — 2026-10-16 diff --git a/docs/spec/compatibility.md b/docs/spec/compatibility.md index 00b4e0b..ac8979d 100644 --- a/docs/spec/compatibility.md +++ b/docs/spec/compatibility.md @@ -52,9 +52,12 @@ additive, as accepting more of an input shape is — but only when every invocation accepted before is parsed and behaves as before. Making an option's value optional does not qualify: `--file --json` reads a file named `--json` today, and would parse `--json` as a flag instead. Accepting a refused value -moves the invocations it accepts from a non-zero exit to `0`, the one way an -invocation crosses zero within `1.x`; a [split of the non-zero -values](#exit-codes-zero-is-success-and-a-failures-value-may-be-refined) never does. +moves the invocations it accepts from a non-zero exit to `0`, one of the two +ways an invocation crosses zero within `1.x`; the other, in the opposite +direction, is a command [coming to refuse a layout basou never +creates](#a-layout-basou-never-creates-may-come-to-be-refused). A [split of the +non-zero values](#exit-codes-zero-is-success-and-a-failures-value-may-be-refined) +never crosses zero. One such value has its meaning fixed now, so that accepting it later cannot surprise anyone: `-` as the file of an input-file flag, a flag that names a @@ -181,6 +184,35 @@ The carve-out is bounded by three things: `1.0`. The row, its fields and today's values of both are listed in [schemas §7.5](schemas.md#75-event-log-integrity-hash-chain--head-anchor). +A row whose status passes may also come to fail, as `tampered`, but only for +a session in a layout basou never creates, as [the next +section](#a-layout-basou-never-creates-may-come-to-be-refused) bounds; the +`reason` it gains names what is not followed. + +### A layout basou never creates may come to be refused + +Within `1.x` a command may come to refuse, or to stop following, an entry of +the `.basou/` store in a form basou never creates — a symlink, or anything +other than the directory or regular file basou writes there — and so fail, or +report a failure, where it succeeded before. A `basou verify` row that passed +then fails as `tampered`: a log read through a symlink as `verified`, say. The +SDK's reads follow their own form of this rule ([item 4 +below](#sdk-errors-the-class-is-the-contract-the-message-is-not)). This is the +one way an invocation moves from `0` to a non-zero exit within `1.x`, and it is +bounded by three things: + +1. **Nothing changes on a layout basou creates.** On a workspace basou laid + out, a command that succeeded still succeeds, and a row that passed still + passes. +2. **The refusal names what it refuses:** an error or a warning naming the + entry, or a `tampered` row whose `reason` says what is not followed. +3. **The changelog lists each such change.** + +`basou verify`'s `symlink` and `not_a_directory` reasons, for an entry named as +a session that is not a directory, and its `events_not_a_file` and +`yaml_not_a_file` reasons, for a session file that is not a regular file, were +added this way before `1.0`. + ### The import envelope version may move at a minor `basou session import` and `basou import` require the envelope's diff --git a/docs/spec/schemas.md b/docs/spec/schemas.md index 6ae4d9f..65ac289 100644 --- a/docs/spec/schemas.md +++ b/docs/spec/schemas.md @@ -986,7 +986,7 @@ Per-session verdicts: | `incomplete` | chained log but `session.yaml` is entirely absent (an import crashed between the two writes); a re-import repairs it | 0 | | `in_progress` | chained log on a still-live session (`initialized` / `running` / `waiting_approval`); the internal chain is verified, the mutable tail and not-yet-written anchor are forgiven | 0 | | `unsupported` | `session.yaml` was written by a newer basou, whose rules verify does not know: its format major is not 0 (nothing is judged, the log included), or it is a `0.x.y` newer than the one this basou writes and the verdict would otherwise be `tampered`; upgrade basou to verify it | non-zero | -| `tampered` | a real break: bad back-pointer or genesis, foreign `session_id`, torn tail (on an at-rest session), blank or malformed line, anchor missing / mismatching, an `integrity` key left behind with no chained log, on a chained log a `session.yaml` that verify cannot read what it needs from (`yaml_unreadable`, below), or a symlink or file where the session's directory should be (`symlink`, `not_a_directory`) | non-zero | +| `tampered` | a real break: bad back-pointer or genesis, foreign `session_id`, torn tail (on an at-rest session), blank or malformed line, anchor missing / mismatching, an `integrity` key left behind with no chained log, on a chained log a `session.yaml` that verify cannot read what it needs from (`yaml_unreadable`, below), a symlink or file where the session's directory should be (`symlink`, `not_a_directory`), or a symlink or anything but a regular file where its `events.jsonl` or `session.yaml` should be (`events_not_a_file`, `yaml_not_a_file`) | non-zero | **What verify reads from `session.yaml`.** Three fields, each against its own schema: the `schema_version` format gate, the `integrity` anchor, and the @@ -1018,7 +1018,8 @@ Whether the whole document loads is reported beside the verdict: `"session_yaml_invalid": true` on the row of `basou verify --json`, and a note on the human-readable line, whenever `session.yaml` exists but does not parse or fails the full session schema — on `unsupported` and `yaml_unreadable` rows -as well as beside a verdict decided on the three fields. The commands that +as well as beside a verdict decided on the three fields — unless verify does +not read it at all, as for a layout it does not follow (below). The commands that read the whole document skip such a session with the same code; they also skip an I/O failure under it, which makes verify abort instead. @@ -1076,13 +1077,30 @@ import of a source, so before importing a session as new it warns once, naming every such entry, that a session there may be imported again. `task reconcile` and `task refresh-linkage` leave a reference to it as it is. +Inside a session's directory, an `events.jsonl` or a `session.yaml` that is a +symlink, whatever it points to, or anything but a regular file — a directory, +or a FIFO, which a read would wait on — is not followed either, and the log is +not read. Its row is `tampered` with `reason` `events_not_a_file` or +`yaml_not_a_file`, naming the file; when both files are such, either may be +given. A `session.yaml` that is a regular file is still read beside an +`events.jsonl` that is not, so its version decides as above — `unsupported` +at a format major other than 0 or at a newer `0.x.y` — and +`session_yaml_invalid` is reported as on any row. Like a `symlink` row, such a +row judges the store's layout, not the log, and putting the file back as a +regular file repairs it. `session rechain` skips such a session with reason +`events_not_a_file` or `yaml_not_a_file` and exits non-zero, and an in-place +re-import skips it as a prior whose chain fails verification. What the other +commands do with it is in +[workspace](workspace.md#a-sessions-files-are-not-followed). + The workspace's own `.basou/sessions` is not followed either: when it is a symlink or a file, the commands that read or write the workspace's sessions stop with an error saying so — `basou verify` included, which then prints no array. A federated mirror, another host's store registered in `~/.basou/hosts.yaml`, is a read-only copy kept outside the store to begin with, so its `.basou/sessions` may be a symlink; an entry in it that is not a -directory is still not followed. +directory is still not followed, and neither is a session file in it that is +not a regular file. Each row is an object with these fields: @@ -1090,10 +1108,10 @@ Each row is an object with these fields: |---|---|---|---| | `session_id` | string | always | the session's full id (the entry's name), also when `--session` was given a prefix | | `status` | string | always | the verdict, from the table above | -| `event_count` | non-negative integer | always | the complete (newline-terminated) lines of `events.jsonl`, as this basou splits the file; an unterminated tail is not counted, and a missing or empty file counts `0`. On an `unsupported` row it counts lines, not necessarily the newer writer's events; on a `symlink` or `not_a_directory` row it is `0`, as nothing is read | +| `event_count` | non-negative integer | always | the complete (newline-terminated) lines of `events.jsonl`, as this basou splits the file; an unterminated tail is not counted, and a missing or empty file counts `0`. On an `unsupported` row it counts lines, not necessarily the newer writer's events; on a `symlink`, `not_a_directory`, `events_not_a_file` or `yaml_not_a_file` row, and on an `unsupported` row whose `events.jsonl` is not a regular file, it is `0`, as the log is not read | | `reason` | string | always on `tampered` and `incomplete`; today on no other status | what broke, from the table below; read it together with `status`, never instead of it | | `line` | positive integer | when one line of the log broke | the 1-based number of the first line that broke; for `torn_tail`, the number the unterminated tail would have | -| `session_yaml_invalid` | `true` | when `session.yaml` exists but does not load as a whole document | described above; the field is left out rather than set to `false` | +| `session_yaml_invalid` | `true` | when `session.yaml` exists but does not load as a whole document | described above; the field is left out rather than set to `false`, and where verify does not read `session.yaml` — on a `symlink`, `not_a_directory` or `yaml_not_a_file` row, and on an `events_not_a_file` row whose `session.yaml` is not a regular file either | `reason` takes these values today. `unsupported` carries neither `reason` nor `line` today, also where it stands in for a `tampered` result at a newer @@ -1117,6 +1135,8 @@ and never changes whether that status fails the command. | `yaml_missing` | `incomplete` | no | a chained log has no `session.yaml` | | `symlink` | `tampered` | no | the entry named as the session is a symlink, whatever it points to; it is not followed | | `not_a_directory` | `tampered` | no | the entry named as the session is neither a directory nor a symlink (a file, for instance) | +| `events_not_a_file` | `tampered` | no | the session's `events.jsonl` is a symlink, whatever it points to, or anything but a regular file (a directory, a FIFO); it is not followed | +| `yaml_not_a_file` | `tampered` | no | the session's `session.yaml` is a symlink, whatever it points to, or anything but a regular file; it is not followed | **Reading the output.** The shape changes only as [compatibility](compatibility.md#basou-verify-verdicts-may-gain-values-on-the-failing-side) diff --git a/docs/spec/workspace.md b/docs/spec/workspace.md index 2874250..2e65bc0 100644 --- a/docs/spec/workspace.md +++ b/docs/spec/workspace.md @@ -142,6 +142,64 @@ through it: - `orient`, `handoff generate`, `report generate` and `basou view` leave it out of what they show. +### A session's files are not followed + +basou writes a session's `events.jsonl` and `session.yaml` as regular files +and never as anything else. When either of them, inside a session's +directory, is a symlink, whatever it points to, or anything other than a +regular file — a directory, or a FIFO, which a read would wait on — it is not +followed: no command reads through it or waits on it, and the commands that +add to an existing session or finalize one write nothing through it. This +holds for a federated mirror's sessions too, although its `.basou/sessions` +may be a symlink. An absent file is not refused: a session with no +`events.jsonl` has no events, as before. + +- `basou verify` reports the session `tampered` with `reason` + `events_not_a_file` or `yaml_not_a_file` — or `unsupported`, when only the + `events.jsonl` is such and the `session.yaml` was written by a newer basou — + and `session rechain` skips it with reason `events_not_a_file` or + `yaml_not_a_file` and exits non-zero; see [schemas](schemas.md). +- The commands that read the session by its id stop with ` of session + is not a regular file; a symlink or anything else there is not + followed`, before they record anything: `session show`, `approval show`, + `approval approve` and `reject` (which leave the approval pending), and the + commands that record into an existing session or finalize one — `session + note`, `decision record`, a `task` command attaching to a session, and + `exec` and `run` for the session they started, which they check at each + event they record and when they finalize it. `basou view`'s session page + answers `404` with that message for a `session.yaml`; for an + `events.jsonl` it shows the session with an empty timeline marked as + degraded, as for any log it cannot read. +- The commands that load sessions to list or summarize them leave out a + session whose `session.yaml` is such, with a warning naming its full id: + `session list` says `Skipped : session.yaml is not a regular file (a + symlink or anything else is not followed)`, and `orient`, `stats`, `handoff + generate`, `decisions generate`, `report generate`, `review-gaps` and + `decision gaps` say `Skipped : session_yaml_not_a_file`. A session whose + `events.jsonl` is such is kept, and its events are treated as unreadable, as + for any log that cannot be read (`events_jsonl_unreadable`). +- `import` cannot tell whether a session whose `session.yaml` is such holds an + earlier import of a source, so before importing a session as new it warns + once, naming every such session, that a session there may be imported + again. An in-place re-import of a session whose `events.jsonl` or + `session.yaml` is such is skipped, as one whose prior chain fails + verification. `task refresh-linkage` leaves a link to a session whose + `session.yaml` is such as it is. +- `basou board measure` counts such a session in `integrity` as `basou + verify` judges it. Each other section is not measured when a file it reads + is such: `trail`, `review_gaps` and `effort` read the `events.jsonl`, and + `review_gaps`, `freshness` and `effort` the `session.yaml`. +- In the SDK, `listSessions` and `getSession` leave out a session whose + `session.yaml` is such, as one whose `session.yaml` cannot be read, and + report it to `onDiagnostic`; `readEvents` and `streamEvents` still read the + `events.jsonl` of such a session. For an `events.jsonl` that is such, they + return no events and report it to `onDiagnostic` as an unreadable log + (`events_jsonl_unreadable`), the words `stats` and the renderers use for + it. + +Like the checks of the store's directories, this detects a file already in +such a form; it does not race-proof the filesystem. + ### tasks/ details - Listing tasks reads `.basou/tasks/index.json` (a small JSON cache of diff --git a/packages/cli/src/commands/approval.test.ts b/packages/cli/src/commands/approval.test.ts index 6f58f77..3e0d035 100644 --- a/packages/cli/src/commands/approval.test.ts +++ b/packages/cli/src/commands/approval.test.ts @@ -473,15 +473,17 @@ describe("doRunApprovalShow", () => { }); it("case 13: events.jsonl I/O failure surfaces Failed to read events.jsonl", async () => { + // A directory in place of events.jsonl is refused as not a regular file + // rather than read, so the failure is made with chmod, which cannot block + // the superuser. + if (process.getuid?.() === 0) return; const repo = await setupInitedRepo(); const approvalId = APPR("P01"); const sessionId = SES("S01"); await createApproval(repo, { id: approvalId, sessionId }); - // Drop a *directory* in place of events.jsonl so the read fails with - // EISDIR; this is a deterministic substitute for chmod-based exclusion - // that does not require running the test as a non-root user. const sessionDir = await ensureSessionDir(repo, sessionId); - await mkdir(join(sessionDir, "events.jsonl")); + await writeFile(join(sessionDir, "events.jsonl"), ""); + await chmod(join(sessionDir, "events.jsonl"), 0o000); let captured: unknown; try { await doRunApprovalShow(approvalId, {}, { cwd: repo }); @@ -1120,6 +1122,106 @@ describe.skipIf(process.platform === "win32")("a recorded session that is not a }); }); +// POSIX only: symlinks need privileges on Windows, and it has no mkfifo. +describe.skipIf(process.platform === "win32")( + "a recorded session whose events.jsonl or session.yaml is not a regular file", + () => { + const notAFile = (file: string, id: string): string => + `${file} of session ${id} is not a regular file; a symlink or anything else there is not followed`; + + // A running (attachable) session in the store with one requested event, + // whose `file` is moved out and linked back in: following the link would + // succeed. Returns the moved file. + async function sessionWithLinkedFile( + repo: string, + sessionId: string, + approvalId: string, + file: "events.jsonl" | "session.yaml", + ): Promise { + const paths = basouPaths(repo); + await createApproval(repo, { id: approvalId, sessionId }); + await appendRequestedEvent(repo, sessionId, approvalId, "2026-05-04T10:00:00+09:00", "E51"); + await writeYamlFile(join(paths.sessions, sessionId, "session.yaml"), { + schema_version: "0.1.0", + session: { + id: sessionId, + task_id: null, + workspace_id: FIXED_WS_ID, + source: { kind: "claude-code", version: "0.1.0" }, + started_at: "2026-05-04T09:00:00+09:00", + status: "running", + working_directory: "~/projects/example", + invocation: { command: "claude", args: [], exit_code: null }, + related_files: [], + events_log: "events.jsonl", + summary: null, + }, + }); + const outside = join(repo, `moved-${file}`); + await rename(join(paths.sessions, sessionId, file), outside); + await symlink(outside, join(paths.sessions, sessionId, file)); + return outside; + } + + for (const file of ["events.jsonl", "session.yaml"] as const) { + it(`approve and reject write nothing when ${file} is a symlink, and leave the approval pending`, async () => { + const repo = await setupInitedRepo(); + const approvalId = APPR("P51"); + const sessionId = SES("S51"); + const outside = await sessionWithLinkedFile(repo, sessionId, approvalId, file); + const paths = basouPaths(repo); + const before = await snapshotTree(join(paths.sessions, sessionId)); + const outsideBefore = await readFile(outside, "utf8"); + + const err = captureStderr(); + await runApprovalApprove(approvalId, {}, { cwd: repo }); + expect(process.exitCode).toBe(1); + expect(joinCalls(err)).toContain(notAFile(file, sessionId)); + process.exitCode = 0; + await runApprovalReject(approvalId, { reason: "no" }, { cwd: repo }); + expect(process.exitCode).toBe(1); + + expect(await readFile(outside, "utf8")).toBe(outsideBefore); + expect(await snapshotTree(join(paths.sessions, sessionId))).toEqual(before); + expect(await readdir(paths.approvals.pending)).toContain(`${approvalId}.yaml`); + expect(await readdir(paths.approvals.resolved).catch(() => [])).not.toContain( + `${approvalId}.yaml`, + ); + }); + } + + it("show stops with the error when events.jsonl is a FIFO", async () => { + const repo = await setupInitedRepo(); + const approvalId = APPR("P52"); + const sessionId = SES("S52"); + const outside = await sessionWithLinkedFile(repo, sessionId, approvalId, "events.jsonl"); + await rm(join(basouPaths(repo).sessions, sessionId, "events.jsonl")); + await execFileAsync("mkfifo", [join(basouPaths(repo).sessions, sessionId, "events.jsonl")]); + await rm(outside); + const out = captureStdout(); + const err = captureStderr(); + await runApprovalShow(approvalId, {}, { cwd: repo }); + expect(process.exitCode).toBe(1); + expect(joinCalls(err)).toContain(notAFile("events.jsonl", sessionId)); + expect(joinCalls(out)).toBe(""); + }); + + it("control: the same session with its file back in place is approved", async () => { + const repo = await setupInitedRepo(); + const approvalId = APPR("P53"); + const sessionId = SES("S53"); + const outside = await sessionWithLinkedFile(repo, sessionId, approvalId, "session.yaml"); + const yaml = join(basouPaths(repo).sessions, sessionId, "session.yaml"); + await rm(yaml); + await rename(outside, yaml); + captureStdout(); + await runApprovalApprove(approvalId, {}, { cwd: repo }); + expect(process.exitCode).not.toBe(1); + expect(await readEventsLines(repo, sessionId)).toHaveLength(2); + }); + }, +); + /** Every file under `dir`, with its bytes, so a test can prove nothing changed. */ async function snapshotTree(dir: string): Promise> { const out: Record = {}; diff --git a/packages/cli/src/commands/approval.ts b/packages/cli/src/commands/approval.ts index 695b629..6c25a39 100644 --- a/packages/cli/src/commands/approval.ts +++ b/packages/cli/src/commands/approval.ts @@ -28,6 +28,7 @@ import { readSessionYaml, readYamlFile, replayEvents, + SessionFileNotAFileError, type UnfollowedApprovalEntry, } from "@basou/core"; import type { Command } from "commander"; @@ -486,11 +487,13 @@ async function doRunApprovalResolve( // `verify` would otherwise read as tampered (the at-rest anchor would no // longer match the extended log). Only a positively read non-attachable // status blocks; a missing or unreadable session.yaml falls through to the - // pre-existing behavior. + // pre-existing behavior. A session.yaml that is not a regular file is not + // followed, and the approval stays pending. let sessionStatus: string | null = null; try { sessionStatus = (await readSessionYaml(paths, approval.session_id)).session.status; - } catch { + } catch (error: unknown) { + if (error instanceof SessionFileNotAFileError) throw error; sessionStatus = null; } const attachable = diff --git a/packages/cli/src/commands/exec.test.ts b/packages/cli/src/commands/exec.test.ts index 53d575f..61610f0 100644 --- a/packages/cli/src/commands/exec.test.ts +++ b/packages/cli/src/commands/exec.test.ts @@ -158,6 +158,44 @@ describe("runExec", () => { }, ); + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32").each(["symlink", "fifo"] as const)( + "does not follow its session.yaml when it is no longer a regular file (%s)", + async (how) => { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + const outside = join(repo, "moved-session.yaml"); + let before = ""; + // Just before the status write to running, the session.yaml is moved + // out and a symlink to it, or a FIFO, is put in its place: following + // the link would rewrite the copy, and reading the FIFO would block. + const swapping = async (sessionDir: string, event: unknown): Promise => { + await appendEvent(sessionDir, event); + if ((event as { type?: string }).type !== "session_status_changed") return; + await rename(join(sessionDir, "session.yaml"), outside); + if (how === "symlink") await symlink(outside, join(sessionDir, "session.yaml")); + else await execFileAsync("mkfifo", [join(sessionDir, "session.yaml")]); + before = await readFile(outside, "utf8"); + }; + const runner = makeFakeRunner({ exit_code: 0 }); + const run = vi.spyOn(runner, "run"); + await expect( + runExec( + "node", + [], + { cwd: repo, snapshot: false }, + { runner, now: () => FIXED_DATE, appendEvent: swapping }, + ), + ).rejects.toThrow( + /^session\.yaml of session ses_\w+ is not a regular file; a symlink or anything else there is not followed$/, + ); + expect(await readFile(outside, "utf8")).toBe(before); + expect(before).toContain("status: initialized"); + expect(run).not.toHaveBeenCalled(); + expect((await readdir(paths.sessions)).length).toBe(1); + }, + ); + // POSIX only: creating a symlink needs privileges on Windows. it.skipIf(process.platform === "win32")( "starts no session when .basou/locks is a symlink", diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index 2ddbfb4..054e55a 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -14,6 +14,7 @@ import { EVENT_SCHEMA_VERSION, finalizeSessionYaml, getSnapshot, + lstatSessionFile, overwriteYamlFile, type PrefixedId, type ProcessRunner, @@ -24,6 +25,7 @@ import { readYamlFile, SESSION_SCHEMA_VERSION, type Session, + SessionFileNotAFileError, SessionSchema, sanitizeWorkingDirectory, writeObservedDuration, @@ -201,7 +203,7 @@ export async function runExec( // session.yaml writer (e.g. a task attach setting task_id on this session). const runningLock = await acquireLock(paths, "session", sessionId); try { - await mutateSessionYaml(sessionYamlPath, (s) => { + await mutateSessionYaml(paths, sessionId, (s) => { s.session.status = "running"; }); } finally { @@ -439,9 +441,19 @@ function buildInitialSession(input: { } async function mutateSessionYaml( - filePath: string, + paths: BasouPaths, + sessionId: string, mutator: (session: Session) => void, ): Promise { + // A session.yaml that is not a regular file is not followed, as by every + // other reader of a session. + const kind = await lstatSessionFile(paths, sessionId, "session.yaml").catch((error: unknown) => { + throw new Error("Failed to read YAML file", { cause: error }); + }); + if (kind === "symlink" || kind === "not_a_file") { + throw new SessionFileNotAFileError(sessionId, "session.yaml", kind); + } + const filePath = join(paths.sessions, sessionId, "session.yaml"); const raw = await readYamlFile(filePath); const parsed = SessionSchema.parse(raw); mutator(parsed); diff --git a/packages/cli/src/commands/handoff.test.ts b/packages/cli/src/commands/handoff.test.ts index b63e388..73bf58e 100644 --- a/packages/cli/src/commands/handoff.test.ts +++ b/packages/cli/src/commands/handoff.test.ts @@ -237,7 +237,8 @@ describe("basou handoff generate", () => { const repo = await setupInitedRepo(); const id = SES("X05"); await placeSession(repo, { id, status: "running" }); - // Replace events.jsonl with a directory so the read fails with EISDIR. + // Replace events.jsonl with a directory, which is not a regular file, so + // the read is refused. await mkdir(join(basouPaths(repo).sessions, id, "events.jsonl"), { recursive: true }); captureStdout(); const err = captureStderr(); diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts index f20ef9c..c7aa09f 100644 --- a/packages/cli/src/commands/import.test.ts +++ b/packages/cli/src/commands/import.test.ts @@ -1517,6 +1517,29 @@ describe.skipIf(process.platform === "win32")( } }); + it("warns once, with the full id, about a session whose session.yaml is not a regular file", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + await writeTranscript(repo, "sess-2", actionTranscript(repo)); + const ctx = { cwd: repo, claudeProjectsDir: getProjectsRoot() }; + await doRunImportClaudeCode({ session: "sess-1" }, ctx); + const [linked] = await listSessionDirs(repo); + if (linked === undefined) throw new Error("fixture import failed"); + // The earlier import's session.yaml moved out and linked back in. + const yaml = join(basouPaths(repo).sessions, linked, "session.yaml"); + await rename(yaml, join(repo, "moved-session.yaml")); + await symlink(join(repo, "moved-session.yaml"), yaml); + + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + await doRunImportClaudeCode({ all: true }, ctx); + const warnings = err.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(WARNING)); + expect(warnings).toEqual([ + `Import: the session.yaml of ${linked} is not a regular file (a symlink or anything else is not followed), so it was not checked for an earlier import; a session imported there may be imported again`, + ]); + // The link is not followed, so sess-1 is imported again beside it. + expect((await listSessionDirs(repo)).filter((d) => d !== linked)).toHaveLength(2); + }); + it("a .basou/sessions that is a symlink stops the import, --dry-run included", async () => { const repo = await setupInitedRepo(); await writeTranscript(repo, "sess-1", actionTranscript(repo)); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 7b8d944..01fc273 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -30,6 +30,7 @@ import { reimportPreservingId, SESSION_IMPORT_SCHEMA_VERSION, type Session, + SessionFileNotAFileError, type SessionImportPayload, SessionImportPayloadSchema, type SessionSourceKind, @@ -380,13 +381,15 @@ async function importDerivedSessions( // (a dry run takes no lock and is not stopped). await assertSessionStoreSafe(paths); if (options.dryRun !== true) await assertLockStoreSafe(paths); - const { byExternalId: existingByExternalId, notDirectories } = await loadExistingByExternalId( - paths, - sourceKind, - ); - // An entry named as a session that is not a directory is not followed, so - // an earlier import it holds is not seen. Said once, when a session is about - // to be imported as new. + const { + byExternalId: existingByExternalId, + notDirectories, + yamlNotFiles, + } = await loadExistingByExternalId(paths, sourceKind); + // An entry named as a session that is not a directory is not followed, nor + // is a session.yaml that is not a regular file, so an earlier import either + // holds is not seen. Said once, when a session is about to be imported as + // new. let notDirectoriesWarned = false; // Session ids imported earlier in THIS run, so two source files that map to // one session id never double-import within a single invocation. @@ -530,14 +533,28 @@ async function importDerivedSessions( counts.replaced++; } - if (priors.length === 0 && notDirectories.length > 0 && !notDirectoriesWarned) { + if ( + priors.length === 0 && + (notDirectories.length > 0 || yamlNotFiles.length > 0) && + !notDirectoriesWarned + ) { notDirectoriesWarned = true; - const one = notDirectories.length === 1; - console.error( - `Import: ${notDirectories.join(", ")} ${one ? "is not a directory" : "are not directories"} ` + - `(a symlink or a file is not followed), so ${one ? "it was" : "they were"} not checked ` + - "for an earlier import; a session imported there may be imported again", - ); + if (notDirectories.length > 0) { + const one = notDirectories.length === 1; + console.error( + `Import: ${notDirectories.join(", ")} ${one ? "is not a directory" : "are not directories"} ` + + `(a symlink or a file is not followed), so ${one ? "it was" : "they were"} not checked ` + + "for an earlier import; a session imported there may be imported again", + ); + } + if (yamlNotFiles.length > 0) { + const one = yamlNotFiles.length === 1; + console.error( + `Import: the session.yaml of ${yamlNotFiles.join(", ")} ${one ? "is" : "are"} not a regular file ` + + `(a symlink or anything else is not followed), so ${one ? "it was" : "they were"} not checked ` + + "for an earlier import; a session imported there may be imported again", + ); + } } const result = await importSessionFromJson(paths, manifest, payload, { dryRun: options.dryRun === true, @@ -704,7 +721,11 @@ type PriorImport = { sessionId: string; sourceSizeBytes?: number }; async function loadExistingByExternalId( paths: BasouPaths, sourceKind: SessionSourceKind, -): Promise<{ byExternalId: Map; notDirectories: string[] }> { +): Promise<{ + byExternalId: Map; + notDirectories: string[]; + yamlNotFiles: string[]; +}> { const byExternalId = new Map(); const add = (externalId: string, prior: PriorImport): void => { const list = byExternalId.get(externalId); @@ -716,13 +737,15 @@ async function loadExistingByExternalId( try { ({ dirs: sessionIds, notDirectories } = await enumerateSessionEntries(paths)); } catch { - return { byExternalId, notDirectories: [] }; + return { byExternalId, notDirectories: [], yamlNotFiles: [] }; } + const yamlNotFiles: string[] = []; for (const sessionId of sessionIds) { let session: Session; try { session = await readSessionYaml(paths, sessionId); - } catch { + } catch (error: unknown) { + if (error instanceof SessionFileNotAFileError) yamlNotFiles.push(sessionId); continue; } if (session.session.source.kind !== sourceKind) continue; @@ -740,7 +763,7 @@ async function loadExistingByExternalId( const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null; if (match?.[1] !== undefined) add(match[1], prior); } - return { byExternalId, notDirectories }; + return { byExternalId, notDirectories, yamlNotFiles }; } /** diff --git a/packages/cli/src/commands/run.test.ts b/packages/cli/src/commands/run.test.ts index 51049e9..b79cd58 100644 --- a/packages/cli/src/commands/run.test.ts +++ b/packages/cli/src/commands/run.test.ts @@ -182,6 +182,41 @@ describe("runClaudeCode", () => { }, ); + // POSIX only: creating a symlink needs privileges on Windows. + it.skipIf(process.platform === "win32")( + "does not follow its session.yaml when it is no longer a regular file", + async () => { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + const outside = join(repo, "moved-session.yaml"); + let before = ""; + // Just before the status write to running, the session.yaml is moved + // out and linked back in: following the link would rewrite the copy. + const swapping = async (sessionDir: string, event: unknown): Promise => { + await appendEvent(sessionDir, event); + if ((event as { type?: string }).type !== "session_status_changed") return; + await rename(join(sessionDir, "session.yaml"), outside); + await symlink(outside, join(sessionDir, "session.yaml")); + before = await readFile(outside, "utf8"); + }; + const runner = makeFakeRunner({ exit_code: 0 }); + const run = vi.spyOn(runner, "run"); + await expect( + runClaudeCode( + [], + { cwd: repo, snapshot: false }, + { runner, now: () => FIXED_DATE, resolveCommand: okResolve, appendEvent: swapping }, + ), + ).rejects.toThrow( + /^session\.yaml of session ses_\w+ is not a regular file; a symlink or anything else there is not followed$/, + ); + expect(await readFile(outside, "utf8")).toBe(before); + expect(before).toContain("status: initialized"); + expect(run).not.toHaveBeenCalled(); + expect((await readdir(paths.sessions)).length).toBe(1); + }, + ); + // POSIX only: creating a symlink needs privileges on Windows. it.skipIf(process.platform === "win32")( "starts no session when .basou/locks is a symlink", diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index d348625..48cc683 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -20,6 +20,7 @@ import { type GitSnapshot, getDiff, getSnapshot, + lstatSessionFile, overwriteYamlFile, type PrefixedId, type ProcessRunner, @@ -31,6 +32,7 @@ import { resolveCodexCommand, SESSION_SCHEMA_VERSION, type Session, + SessionFileNotAFileError, SessionSchema, type SessionSourceKind, sanitizeRelatedFiles, @@ -342,7 +344,7 @@ async function runTrackedTool( // session.yaml writer (e.g. a task attach setting task_id on this session). const runningLock = await acquireLock(paths, "session", sessionId); try { - await mutateSessionYaml(sessionYamlPath, (s) => { + await mutateSessionYaml(paths, sessionId, (s) => { s.session.status = "running"; }); } finally { @@ -678,9 +680,19 @@ function buildInitialSession(input: { } async function mutateSessionYaml( - filePath: string, + paths: BasouPaths, + sessionId: string, mutator: (session: Session) => void, ): Promise { + // A session.yaml that is not a regular file is not followed, as by every + // other reader of a session. + const kind = await lstatSessionFile(paths, sessionId, "session.yaml").catch((error: unknown) => { + throw new Error("Failed to read YAML file", { cause: error }); + }); + if (kind === "symlink" || kind === "not_a_file") { + throw new SessionFileNotAFileError(sessionId, "session.yaml", kind); + } + const filePath = join(paths.sessions, sessionId, "session.yaml"); const raw = await readYamlFile(filePath); const parsed = SessionSchema.parse(raw); mutator(parsed); diff --git a/packages/cli/src/commands/session-rechain.test.ts b/packages/cli/src/commands/session-rechain.test.ts index 2c61a7c..78c411f 100644 --- a/packages/cli/src/commands/session-rechain.test.ts +++ b/packages/cli/src/commands/session-rechain.test.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { + chmod, mkdir, mkdtemp, readFile, @@ -276,14 +277,19 @@ describe("basou session rechain", () => { }); it("turns a per-session I/O failure into an error row and keeps sweeping", async () => { + // chmod cannot block the superuser. + if (process.getuid?.() === 0) return; const repo = await setupInitedRepo(); const legacyId = await importLegacySession(repo); - // A session whose events.jsonl is a DIRECTORY: reads fail with a + // A session whose events.jsonl cannot be read: reads fail with a // non-ENOENT error, which must become an error row, not abort the sweep. + // (A directory there is not a regular file, and is skipped instead.) const paths = basouPaths(repo); const brokenId = "ses_01HXABCDEF1234567890ABCBRK"; const brokenDir = join(paths.sessions, brokenId); - await mkdir(join(brokenDir, "events.jsonl"), { recursive: true }); + await mkdir(brokenDir, { recursive: true }); + await writeFile(join(brokenDir, "events.jsonl"), ""); + await chmod(join(brokenDir, "events.jsonl"), 0o000); await writeYamlFile(join(brokenDir, "session.yaml"), { schema_version: "0.1.0", session: { @@ -311,6 +317,76 @@ describe("basou session rechain", () => { expect(process.exitCode).toBe(1); }); + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32")( + "skips a session whose events.jsonl or session.yaml is not a regular file, follows neither, and exits 1", + async () => { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + // Legacy sessions a rechain would chain if it followed the link. + const eventsLinked = await importLegacySession(repo); + const events = join(paths.sessions, eventsLinked, "events.jsonl"); + const outside = join(repo, "moved-events.jsonl"); + await rename(events, outside); + await symlink(outside, events); + const before = await readFile(outside, "utf8"); + const yamlId = await importLegacySession(repo); + const yaml = join(paths.sessions, yamlId, "session.yaml"); + await rm(yaml); + await execFileAsync("mkfifo", [yaml]); + + const out = captureStdout(); + await runSessionRechain({ all: true }, { cwd: repo }); + const text = joinCalls(out); + expect(text).toContain( + `${eventsLinked} skipped (events.jsonl NOT A REGULAR FILE — not followed; inspect with 'basou verify')`, + ); + expect(text).toContain( + `${yamlId} skipped (session.yaml NOT A REGULAR FILE — not followed; inspect with 'basou verify')`, + ); + expect(text).toContain("Sessions: 2 total — 0 rechained, 2 skipped, 0 errors"); + expect(process.exitCode).toBe(1); + + vi.restoreAllMocks(); + process.exitCode = 0; + const json = captureStdout(); + await runSessionRechain({ all: true, json: true }, { cwd: repo }); + expect(JSON.parse(joinCalls(json))).toEqual( + [ + { session_id: eventsLinked, status: "skipped", reason: "events_not_a_file" }, + { session_id: yamlId, status: "skipped", reason: "yaml_not_a_file" }, + ].sort((a, b) => a.session_id.localeCompare(b.session_id)), + ); + expect(process.exitCode).toBe(1); + expect(await readFile(outside, "utf8")).toBe(before); + }, + ); + + // Each reason alone fails the run: a session whose other file is regular + // beside one that is rechained. + it.skipIf(process.platform === "win32").each([ + ["events.jsonl", "events_not_a_file"], + ["session.yaml", "yaml_not_a_file"], + ] as const)("exits 1 for a %s that is not a regular file on its own", async (file, reason) => { + const repo = await setupInitedRepo(); + const paths = basouPaths(repo); + const refused = await importLegacySession(repo); + const legacyId = await importLegacySession(repo); + const path = join(paths.sessions, refused, file); + await rename(path, join(repo, `moved-${file}`)); + await symlink(join(repo, `moved-${file}`), path); + + const out = captureStdout(); + await runSessionRechain({ all: true, json: true }, { cwd: repo }); + expect(JSON.parse(joinCalls(out))).toEqual( + [ + { session_id: refused, status: "skipped", reason }, + { session_id: legacyId, status: "rechained", event_count: 3 }, + ].sort((a, b) => a.session_id.localeCompare(b.session_id)), + ); + expect(process.exitCode).toBe(1); + }); + // POSIX only: creating a symlink needs privileges on Windows. it.skipIf(process.platform === "win32")( "gives a symlink or a file named as a session a row, follows neither, and exits 1", diff --git a/packages/cli/src/commands/session.test.ts b/packages/cli/src/commands/session.test.ts index 972bb6e..efde683 100644 --- a/packages/cli/src/commands/session.test.ts +++ b/packages/cli/src/commands/session.test.ts @@ -5,6 +5,7 @@ import { readdir, readFile, realpath, + rename, rm, symlink, writeFile, @@ -323,6 +324,26 @@ describe("doRunSessionList", () => { expect(lines[3]).toContain("2026-05-07"); }); + it.skipIf(process.platform === "win32")( + "leaves out a session whose session.yaml is a symlink, naming it by its full id", + async () => { + const repo = await setupInitedRepo(); + await createSession(repo, { id: SES("X01") }); + await createSession(repo, { id: SES("X02") }); + const yaml = join(repo, ".basou", "sessions", SES("X02"), "session.yaml"); + await rename(yaml, join(repo, "moved-session.yaml")); + await symlink(join(repo, "moved-session.yaml"), yaml); + const out = captureStdout(); + const err = captureStderr(); + await doRunSessionList({ json: true }, { cwd: repo }); + const rows = JSON.parse(joinCalls(out)) as Array<{ id: string }>; + expect(rows.map((r) => r.id)).toEqual([SES("X01")]); + expect(joinCalls(err)).toBe( + `Skipped ${SES("X02")}: session.yaml is not a regular file (a symlink or anything else is not followed)`, + ); + }, + ); + it("case 4: --json emits an array carrying suspect/suspect_reason fields", async () => { const repo = await setupInitedRepo(); await createSession(repo, { id: SES("X01") }); @@ -838,6 +859,33 @@ describe("doRunSessionShow", () => { expect(joinCalls(err)).toContain("malformed JSON"); }); + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32")( + "stops with the error when session.yaml or events.jsonl is not a regular file, following neither", + async () => { + const repo = await setupInitedRepo(); + const id = SES("Y30"); + const events = SESSION_STARTED_LINE(id, "K03", "2026-05-08T11:00:00+09:00"); + await createSession(repo, { id, events }); + const sessionDir = join(repo, ".basou", "sessions", id); + const yaml = join(sessionDir, "session.yaml"); + await rename(yaml, join(repo, "moved-session.yaml")); + await symlink(join(repo, "moved-session.yaml"), yaml); + const out = captureStdout(); + await expect(doRunSessionShow(id, {}, { cwd: repo })).rejects.toThrow( + `session.yaml of session ${id} is not a regular file; a symlink or anything else there is not followed`, + ); + await rm(yaml); + await rename(join(repo, "moved-session.yaml"), yaml); + await rm(join(sessionDir, "events.jsonl")); + await execFileAsync("mkfifo", [join(sessionDir, "events.jsonl")]); + await expect(doRunSessionShow(id, {}, { cwd: repo })).rejects.toThrow( + `events.jsonl of session ${id} is not a regular file; a symlink or anything else there is not followed`, + ); + expect(joinCalls(out)).toBe(""); + }, + ); + it("case 19: prefix-only `ses_` input is rejected as not found", async () => { const repo = await setupInitedRepo(); await createSession(repo, { id: SES("Z01") }); diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index d412c34..8db99d3 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -17,6 +17,7 @@ import { importSessionFromJson, LOCAL_CLI_EVENT_SOURCE, loadSessionEntries, + lstatSessionFile, normalizeSessionTimestamps, type RechainResult, readAllEvents, @@ -28,6 +29,7 @@ import { resolveTaskId, SESSION_IMPORT_SCHEMA_VERSION, type Session, + SessionFileNotAFileError, SessionImportPayloadSchema, SessionSchema, type SessionStatus, @@ -300,6 +302,16 @@ export async function doRunSessionShow( const sessionDir = join(paths.sessions, sessionId); const sessionYamlPath = join(sessionDir, "session.yaml"); + // A session.yaml that is a symlink or anything but a regular file is not + // followed: the command stops with the error saying so. + const yamlKind = await lstatSessionFile(paths, sessionId, "session.yaml").catch( + (error: unknown) => { + throw new Error("Failed to read session", { cause: error }); + }, + ); + if (yamlKind === "symlink" || yamlKind === "not_a_file") { + throw new SessionFileNotAFileError(sessionId, "session.yaml", yamlKind); + } let session: Session; try { const raw = await readYamlFile(sessionYamlPath); @@ -1024,7 +1036,8 @@ export async function runSessionRechain( * into rows; an I/O failure on one session becomes an `error` row and the * sweep CONTINUES, so one unreadable directory cannot hide the rest of the * report. Exit is non-zero when any session was found `tampered` (an entry - * named as a session that is a symlink or a file counts) or errored + * named as a session that is a symlink or a file counts, and so does an + * events.jsonl or session.yaml in one that is not a regular file) or errored * operationally; plain skips and successes exit 0. */ export async function doRunSessionRechain( @@ -1076,7 +1089,12 @@ export async function doRunSessionRechain( } const tamperedCount = rows.filter( - (r) => r.reason === "tampered" || r.reason === "symlink" || r.reason === "not_a_directory", + (r) => + r.reason === "tampered" || + r.reason === "symlink" || + r.reason === "not_a_directory" || + r.reason === "events_not_a_file" || + r.reason === "yaml_not_a_file", ).length; const errorCount = rows.filter((r) => r.status === "error").length; @@ -1111,6 +1129,10 @@ function renderRechainRow(row: RechainRow, dryRun: boolean): string { if (row.reason === "symlink" || row.reason === "not_a_directory") { return `skipped (${row.reason === "symlink" ? "SYMLINK" : "NOT A DIRECTORY"} — not followed; inspect with 'basou verify')`; } + if (row.reason === "events_not_a_file" || row.reason === "yaml_not_a_file") { + const file = row.reason === "events_not_a_file" ? "events.jsonl" : "session.yaml"; + return `skipped (${file} NOT A REGULAR FILE — not followed; inspect with 'basou verify')`; + } return `skipped (${row.reason})`; case "error": return `error (${row.message})`; diff --git a/packages/cli/src/commands/verify.test.ts b/packages/cli/src/commands/verify.test.ts index 54f4303..bfbdb3b 100644 --- a/packages/cli/src/commands/verify.test.ts +++ b/packages/cli/src/commands/verify.test.ts @@ -228,6 +228,55 @@ describe("basou verify", () => { }, ); + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32")( + "reports a session whose events.jsonl or session.yaml is not a regular file as tampered, following neither", + async () => { + const repo = await setupInitedRepo(); + const importedId = await importChainedSession(repo); + await writeLiveSession(repo); + const sessions = basouPaths(repo).sessions; + // The intact log behind a symlink: following it would verify the session. + const events = join(sessions, importedId, "events.jsonl"); + const outside = join(repo, "moved-events.jsonl"); + await cp(events, outside); + await rm(events); + await symlink(outside, events); + // A FIFO would block a reader that opened it. + const yaml = join(sessions, LIVE_SES_ID, "session.yaml"); + await rm(yaml); + await execFileAsync("mkfifo", [yaml]); + + const out = captureStdout(); + await runVerify({ json: true }, { cwd: repo }); + expect(JSON.parse(joinCalls(out))).toEqual( + [ + { + session_id: importedId, + status: "tampered", + event_count: 0, + reason: "events_not_a_file", + }, + { + session_id: LIVE_SES_ID, + status: "tampered", + event_count: 0, + reason: "yaml_not_a_file", + }, + ].sort((a, b) => (a.session_id < b.session_id ? -1 : 1)), + ); + expect(process.exitCode).toBe(1); + + process.exitCode = 0; + out.mockClear(); + await runVerify({}, { cwd: repo }); + const lines = joinCalls(out).split("\n"); + expect(lines).toContain(`${importedId} TAMPERED (events_not_a_file)`); + expect(lines).toContain(`${LIVE_SES_ID} TAMPERED (yaml_not_a_file)`); + expect(process.exitCode).toBe(1); + }, + ); + it("reports a null line as tampered and still reports every other session", async () => { const repo = await setupInitedRepo(); const importedId = await importChainedSession(repo); @@ -433,6 +482,8 @@ const REASONS: Record, true> = { yaml_missing: true, symlink: true, not_a_directory: true, + events_not_a_file: true, + yaml_not_a_file: true, }; // The cells of each row of the table under `header`, with the first cell's diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index f52ed3f..0b960ca 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { access, cp, @@ -403,6 +403,40 @@ describe("basou view server", () => { }, ); + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32")( + "does not serve a session whose session.yaml is not a regular file, and shows one whose events.jsonl is not without its events", + async () => { + const repo = await setupInitedRepo(); + await writeCodexRollout(repo); + await withServer(repo, {}, async (handle) => { + await postJson(handle, "/api/import/codex", {}); + const listed = await getJson(handle, "/api/sessions"); + const id = (listed.data as { sessions: Array<{ sessionId: string }> }).sessions[0] + ?.sessionId as string; + const sessionDir = join(repo, ".basou", "sessions", id); + const yaml = join(sessionDir, "session.yaml"); + await rename(yaml, join(repo, "moved-session.yaml")); + await symlink(join(repo, "moved-session.yaml"), yaml); + expect(await getJson(handle, `/api/sessions/${id}`)).toEqual({ + status: 404, + data: { + error: `session.yaml of session ${id} is not a regular file; a symlink or anything else there is not followed`, + }, + }); + // Back in place, with a FIFO at events.jsonl instead: the session is + // shown, its timeline empty and marked degraded, and nothing blocks. + await rm(yaml); + await rename(join(repo, "moved-session.yaml"), yaml); + await rm(join(sessionDir, "events.jsonl")); + execFileSync("mkfifo", [join(sessionDir, "events.jsonl")]); + const detail = await getJson(handle, `/api/sessions/${id}`); + expect(detail.status).toBe(200); + expect(detail.data).toMatchObject({ events: [], degraded: true }); + }); + }, + ); + it("serves work stats", async () => { const repo = await setupInitedRepo(); await writeCodexRollout(repo); diff --git a/packages/cli/src/lib/error-render.test.ts b/packages/cli/src/lib/error-render.test.ts index 1ad3708..d8d25fb 100644 --- a/packages/cli/src/lib/error-render.test.ts +++ b/packages/cli/src/lib/error-render.test.ts @@ -357,6 +357,12 @@ describe("printSessionSkip", () => { "Skipped ses_01HXABCDEFG1234567890ABCDE: session_dir_not_directory", ); }); + + it("names a session whose session.yaml is not a regular file by its full id", () => { + const err = captureStderr(); + printSessionSkip("ses_01HXABCDEFG1234567890ABCDE", "session_yaml_not_a_file"); + expect(joinCalls(err)).toBe("Skipped ses_01HXABCDEFG1234567890ABCDE: session_yaml_not_a_file"); + }); }); describe("printSessionListSkip", () => { @@ -387,6 +393,14 @@ describe("printSessionListSkip", () => { "Skipped ses_01HXABCDEFG1234567890ABCDE: not a directory (a symlink or a file is not followed)", ); }); + + it("names a session whose session.yaml is not a regular file by its full id", () => { + const err = captureStderr(); + printSessionListSkip("ses_01HXABCDEFG1234567890ABCDE", "session_yaml_not_a_file"); + expect(joinCalls(err)).toBe( + "Skipped ses_01HXABCDEFG1234567890ABCDE: session.yaml is not a regular file (a symlink or anything else is not followed)", + ); + }); }); // ---------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/error-render.ts b/packages/cli/src/lib/error-render.ts index 050ea22..491bb18 100644 --- a/packages/cli/src/lib/error-render.ts +++ b/packages/cli/src/lib/error-render.ts @@ -209,7 +209,7 @@ export function printSessionSkip(sid: string, reason: SessionSkipReason): void { const short = shortSessionId(sid); if (reason === "events_jsonl_unreadable") { console.error(`Warning: skipped suspect check for ${short}: events.jsonl unreadable`); - } else if (reason === "session_dir_not_directory") { + } else if (reason === "session_dir_not_directory" || reason === "session_yaml_not_a_file") { // The full id: this names an entry the user has to find and fix, and two // such entries can share a short id. console.error(`Skipped ${sid}: ${reason}`); @@ -240,6 +240,11 @@ export function printSessionListSkip(sid: string, reason: SessionSkipReason): vo case "session_dir_not_directory": console.error(`Skipped ${sid}: not a directory (a symlink or a file is not followed)`); break; + case "session_yaml_not_a_file": + console.error( + `Skipped ${sid}: session.yaml is not a regular file (a symlink or anything else is not followed)`, + ); + break; } } diff --git a/packages/cli/src/lib/view-server.ts b/packages/cli/src/lib/view-server.ts index 82abada..960fe5a 100644 --- a/packages/cli/src/lib/view-server.ts +++ b/packages/cli/src/lib/view-server.ts @@ -25,6 +25,7 @@ import { renderDecisions, renderHandoff, resolveViewLanguageFromPaths, + SessionFileNotAFileError, SessionIdSchema, SessionNotADirectoryError, summarizeOrientation, @@ -707,6 +708,8 @@ async function sessionDetail( if (error instanceof Error && error.message === "YAML file not found") { throw new HttpError(404, "Session not found"); } + // Nor is a session.yaml that is a symlink or anything but a regular file. + if (error instanceof SessionFileNotAFileError) throw new HttpError(404, error.message); throw error; } // An unreadable events.jsonl must not 500 the detail view; surface the diff --git a/packages/core/src/board/effort.ts b/packages/core/src/board/effort.ts index c3e711c..0b34b0b 100644 --- a/packages/core/src/board/effort.ts +++ b/packages/core/src/board/effort.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import type { SessionSourceKind } from "../schemas/session.schema.js"; import { type IntervalMs, intervalsIsoToMs, mergeIntervals } from "../stats/active-time.js"; import { computeWorkStats, type SessionWorkStats } from "../stats/work-stats.js"; @@ -11,7 +10,7 @@ import { listSessions, lostAmong } from "./sessions.js"; * the section would change for the same trail and repositories, as when * `basou stats` measures active time differently. */ -export const BOARD_EFFORT_METHOD = 1; +export const BOARD_EFFORT_METHOD = 2; // Which vendor's work each kind of session is. A Record keyed by the union, // so a kind added to the schema fails to compile here until it is placed. @@ -376,7 +375,7 @@ async function readSessions( } let lost = offSchema.size; for (const [sessionId, lines] of malformed) { - lost += await lostAmong(join(paths.sessions, sessionId, "events.jsonl"), [...lines]); + lost += await lostAmong(paths, sessionId, [...lines]); } const problems: string[] = []; if (lost > 0) problems.push(`${lost} event line${lost === 1 ? "" : "s"} could not be read`); diff --git a/packages/core/src/board/freshness.ts b/packages/core/src/board/freshness.ts index 0ed7d80..30eab35 100644 --- a/packages/core/src/board/freshness.ts +++ b/packages/core/src/board/freshness.ts @@ -7,7 +7,7 @@ import { listSessions } from "./sessions.js"; * value of the section would change for the same trail and native logs, as * when `basou orient` judges freshness differently. */ -export const BOARD_FRESHNESS_METHOD = 1; +export const BOARD_FRESHNESS_METHOD = 2; /** * What a dry run of `basou refresh` would do on this host: the sessions it diff --git a/packages/core/src/board/integrity.ts b/packages/core/src/board/integrity.ts index 93b52a1..1614333 100644 --- a/packages/core/src/board/integrity.ts +++ b/packages/core/src/board/integrity.ts @@ -7,7 +7,7 @@ import { listSessions } from "./sessions.js"; * of the section would change for the same trail, as when `basou verify` * judges a session differently or gains a status. */ -export const BOARD_INTEGRITY_METHOD = 1; +export const BOARD_INTEGRITY_METHOD = 2; // Every status `basou verify` gives, in the order its summary line tallies // them. A Record keyed by the union, so a status added to the verifier fails @@ -35,7 +35,8 @@ export type BoardIntegrity = { * How many sessions have each status, every status `basou verify` gives * included at 0, in the order its summary line tallies them. An entry named * as a session that is not a directory (a symlink or a file) is `tampered`, - * as `basou verify` judges it. + * as `basou verify` judges it, and so is a session whose `events.jsonl` or + * `session.yaml` is a symlink or anything but a regular file. */ by_status: Record | null; /** The sessions whose status is not `verified`, whatever it is. */ diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts index 0364343..54b1912 100644 --- a/packages/core/src/board/live.test.ts +++ b/packages/core/src/board/live.test.ts @@ -122,11 +122,11 @@ describe("measureBoardLive", () => { ); expect(m.methods).toEqual({ repos: 1, - trail: 1, - integrity: 1, - review_gaps: 1, - freshness: 1, - effort: 1, + trail: 2, + integrity: 2, + review_gaps: 2, + freshness: 2, + effort: 2, components: 2, }); expect(m.not_found).toEqual([]); diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 146e6b2..daea8a8 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, + rename, rm, stat, symlink, @@ -1766,12 +1767,12 @@ describe("measureBoard: the repos section", () => { const first = await measure(declaration, NOW, ["app"]); expect(first.methods).toEqual({ repos: 1, - trail: 1, - integrity: 1, - review_gaps: 1, + trail: 2, + integrity: 2, + review_gaps: 2, portfolio: 1, - freshness: 1, - effort: 1, + freshness: 2, + effort: 2, components: 2, axis: 2, }); @@ -2458,6 +2459,60 @@ describe("measureBoard: the trail section", () => { }, ); + it.skipIf(process.platform === "win32")( + "is not measured when a session's events.jsonl or session.yaml is not a regular file", + async () => { + const s1 = SES("S01"); + const s2 = SES("S02"); + await placeSession(s1, decided(s1, "E01", DEC("D01"), "t", "2026-09-01T00:00:00Z", "track")); + await placeSession(s2, decided(s2, "E02", DEC("D02"), "u", "2026-09-02T00:00:00Z", "track")); + // A FIFO at events.jsonl: the trail, the review gaps and the effort read + // the events, and opening it would block. + const events = join(paths.sessions, s1, "events.jsonl"); + await rm(events); + execFileSync("mkfifo", [events]); + const m = await measure(declare([])); + expect(m.trail).toEqual({ decisions_all: null, decisions_live: null, tracks_open: null }); + expect(m.not_found).toEqual([ + { at: "trail", reason: "the sessions of the workspace could not be read" }, + { at: "review_gaps", reason: "1 session could not be read, so the counts are not known" }, + { + at: "effort.active_ms", + reason: "1 session could not be read, so the time and tokens are not known", + }, + { + at: "effort.output_tokens", + reason: "1 session could not be read, so the time and tokens are not known", + }, + ]); + // A symlink at session.yaml instead: the trail does not read it; the + // freshness, the review gaps and the effort do, and leave it out. + await rm(events); + await writeFile(events, decided(s1, "E01", DEC("D01"), "t", "2026-09-01T00:00:00Z", "track")); + const yaml = join(paths.sessions, s2, "session.yaml"); + const outside = join(paths.root, "..", "outside-session.yaml"); + await rename(yaml, outside); + await symlink(outside, yaml); + const n = await measure(declare([])); + expect(n.trail.decisions_all).toBe(2); + expect(n.not_found).toEqual([ + { at: "review_gaps", reason: "1 session could not be read, so the counts are not known" }, + { + at: "freshness.newest_session_at", + reason: "1 session could not be read, so the newest may be missing", + }, + { + at: "effort.active_ms", + reason: "1 session could not be read, so the time and tokens are not known", + }, + { + at: "effort.output_tokens", + reason: "1 session could not be read, so the time and tokens are not known", + }, + ]); + }, + ); + it.skipIf( process.platform === "win32" || (typeof process.getuid === "function" && process.getuid() === 0), @@ -2683,6 +2738,30 @@ describe("measureBoard: the integrity section", () => { }, ); + it.skipIf(process.platform === "win32")( + "counts a session whose events.jsonl or session.yaml is not a regular file as tampered, as basou verify does", + async () => { + await placeChained(SES("S01")); + await placeChained(SES("S02")); + await placeChained(SES("S03")); + // A FIFO would block a reader that opened it; a symlink to the intact + // file would be verified by one that followed it. + const events = join(paths.sessions, SES("S02"), "events.jsonl"); + await rm(events); + execFileSync("mkfifo", [events]); + const yaml = join(paths.sessions, SES("S03"), "session.yaml"); + const outside = join(paths.root, "..", "outside-session.yaml"); + await rename(yaml, outside); + await symlink(outside, yaml); + const m = await measure(declare([])); + expect(m.integrity).toMatchObject({ + by_status: { verified: 1, tampered: 2 }, + not_verified: 2, + }); + expect(m.not_found.filter((n) => n.at === "integrity")).toEqual([]); + }, + ); + it.skipIf( process.platform === "win32" || (typeof process.getuid === "function" && process.getuid() === 0), @@ -2805,12 +2884,12 @@ describe("measureBoard: the integrity section", () => { const first = await measure(declaration); expect(first.methods).toEqual({ repos: 1, - trail: 1, - integrity: 1, - review_gaps: 1, + trail: 2, + integrity: 2, + review_gaps: 2, portfolio: 1, - freshness: 1, - effort: 1, + freshness: 2, + effort: 2, components: 2, axis: 2, }); @@ -3065,12 +3144,12 @@ describe("measureBoard: the review_gaps section", () => { const none = await measure(declaration); expect(none.methods).toEqual({ repos: 1, - trail: 1, - integrity: 1, - review_gaps: 1, + trail: 2, + integrity: 2, + review_gaps: 2, portfolio: 1, - freshness: 1, - effort: 1, + freshness: 2, + effort: 2, components: 2, axis: 2, }); @@ -3185,12 +3264,12 @@ describe("measureBoard: the portfolio section", () => { const bare = await measure(declaration); expect(bare.methods).toEqual({ repos: 1, - trail: 1, - integrity: 1, - review_gaps: 1, + trail: 2, + integrity: 2, + review_gaps: 2, portfolio: 1, - freshness: 1, - effort: 1, + freshness: 2, + effort: 2, components: 2, axis: 2, }); @@ -3364,7 +3443,7 @@ describe("measureBoard: the freshness section", () => { updatedSessions: 1, unverifiableSessions: 0, })); - expect(m.methods.freshness).toBe(1); + expect(m.methods.freshness).toBe(2); const other = await run(async () => ({ newSessions: 4, updatedSessions: 0, @@ -3678,7 +3757,7 @@ describe("measureBoard: the effort section", () => { await placeWork(); const m = await measure(declare([], TOKYO)); const d = boardDeclarationSnapshot(declare([], TOKYO)); - expect(m.methods.effort).toBe(1); + expect(m.methods.effort).toBe(2); // Later the same day, nothing else changed. expect((await measure(declare([], TOKYO), new Date("2026-10-05T09:00:00.000Z"))).digest).toBe( m.digest, diff --git a/packages/core/src/board/review-gaps.ts b/packages/core/src/board/review-gaps.ts index a483d4e..d21f209 100644 --- a/packages/core/src/board/review-gaps.ts +++ b/packages/core/src/board/review-gaps.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import { findReviewGaps, type ReviewGapsSummary, @@ -13,7 +12,7 @@ import { listSessions, lostAmong } from "./sessions.js"; * review-gaps` judges a unit of work differently, gains a verdict or changes * its default window. */ -export const BOARD_REVIEW_GAPS_METHOD = 1; +export const BOARD_REVIEW_GAPS_METHOD = 2; // Every verdict `basou review-gaps` gives, in the order its type lists them. // A Record keyed by the union, so a verdict added to the surfacer fails to @@ -91,7 +90,7 @@ export async function measureReviewGaps(input: { paths: BasouPaths; now: Date }) let lost = offSchema.size; for (const [sessionId, lines] of malformed) { - lost += await lostAmong(join(input.paths.sessions, sessionId, "events.jsonl"), [...lines]); + lost += await lostAmong(input.paths, sessionId, [...lines]); } const problems: string[] = []; if (lost > 0) problems.push(`${lost} event line${lost === 1 ? "" : "s"} could not be read`); diff --git a/packages/core/src/board/sessions.test.ts b/packages/core/src/board/sessions.test.ts new file mode 100644 index 0000000..8f66b64 --- /dev/null +++ b/packages/core/src/board/sessions.test.ts @@ -0,0 +1,51 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; +import { lostAmong } from "./sessions.js"; + +const SES = "ses_01HXABCDEF1234567890ABCLS1"; +// Line 1 is not JSON and complete; line 2 is a torn last line, a write that +// has not finished, which is not counted as lost. +const BODY = '{"bad\n{"torn'; + +let root: string; +let paths: BasouPaths; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "basou-board-sessions-")); + paths = await ensureBasouDirectory(root); + await mkdir(join(paths.sessions, SES)); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("lostAmong", () => { + it("does not count a torn last line of a regular file", async () => { + await writeFile(join(paths.sessions, SES, "events.jsonl"), BODY); + expect(await lostAmong(paths, SES, [1, 2])).toBe(1); + }); + + it("counts every line when the log is missing", async () => { + expect(await lostAmong(paths, SES, [1, 2])).toBe(2); + }); + + // POSIX only: symlinks need privileges on Windows, and it has no mkfifo. + it.skipIf(process.platform === "win32")( + "reads nothing through a symlink and does not wait on a FIFO: every line counts", + async () => { + // A reader that followed the link would find the torn line and count 1. + const outside = join(root, "outside-events.jsonl"); + await writeFile(outside, BODY); + await symlink(outside, join(paths.sessions, SES, "events.jsonl")); + expect(await lostAmong(paths, SES, [1, 2])).toBe(2); + await rm(join(paths.sessions, SES, "events.jsonl")); + execFileSync("mkfifo", [join(paths.sessions, SES, "events.jsonl")]); + expect(await lostAmong(paths, SES, [1, 2])).toBe(2); + }, + ); +}); diff --git a/packages/core/src/board/sessions.ts b/packages/core/src/board/sessions.ts index 01e7c1d..a9d6435 100644 --- a/packages/core/src/board/sessions.ts +++ b/packages/core/src/board/sessions.ts @@ -1,5 +1,6 @@ -import { lstat, readFile } from "node:fs/promises"; +import { lstat } from "node:fs/promises"; import type { BasouPaths } from "../storage/basou-dir.js"; +import { readSessionFile } from "../storage/session-dir.js"; import { enumerateSessionEntries, type SessionDirEntries } from "../storage/sessions.js"; /** The entries named as sessions in the workspace's store, or why they were not listed. */ @@ -31,17 +32,24 @@ export async function listSessions(paths: BasouPaths): Promise { } /** - * How many of the lines of an events log that were not JSON were lost events: - * all but a torn last line, one with no newline after it, which is a write - * that has not finished. The log is read once, however many there are. + * How many of the lines of a session's events log that were not JSON were + * lost events: all but a torn last line, one with no newline after it, which + * is a write that has not finished. The log is read once, however many there + * are, and only when it is a regular file: one that cannot be read, or is no + * longer a regular file, counts every line as lost. */ -export async function lostAmong(eventsLog: string, malformed: readonly number[]): Promise { - let body: Buffer; +export async function lostAmong( + paths: BasouPaths, + sessionId: string, + malformed: readonly number[], +): Promise { + let body: Buffer | null; try { - body = await readFile(eventsLog); + body = await readSessionFile(paths, sessionId, "events.jsonl"); } catch { return malformed.length; } + if (body === null) return malformed.length; if (body.length === 0 || body[body.length - 1] === 0x0a) return malformed.length; let newlines = 0; for (const byte of body) if (byte === 0x0a) newlines++; diff --git a/packages/core/src/board/trail.ts b/packages/core/src/board/trail.ts index bfdaed6..fa31f2e 100644 --- a/packages/core/src/board/trail.ts +++ b/packages/core/src/board/trail.ts @@ -1,4 +1,3 @@ -import { join } from "node:path"; import { type ReplayWarning, readAllEvents } from "../events/event-replay.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { loadTaskEntries, type TaskSkipReason } from "../storage/tasks.js"; @@ -8,7 +7,7 @@ import { listSessions, lostAmong } from "./sessions.js"; * The version of how the `trail` section measures. Raised whenever a value of * the section would change for the same trail. */ -export const BOARD_TRAIL_METHOD = 1; +export const BOARD_TRAIL_METHOD = 2; /** An open track: a decision recorded with `kind: "track"` and not voided. */ export type BoardTrack = { id: string; title: string }; @@ -18,9 +17,10 @@ export type BoardTrack = { id: string; title: string }; * decisions.md), on this host only. A decision recorded twice under one id * counts once. All three are null, with one not_found entry at `trail`, when * an event line could not be read (a torn last line, a write in progress, is - * not counted as one), an entry named as a session is not a directory, or - * the sessions cannot be listed (or `.basou/sessions` is refused, being a - * symlink or not a directory). + * not counted as one), an entry named as a session is not a directory, a + * session's `events.jsonl` cannot be read (one that is a symlink or anything + * but a regular file is not followed), or the sessions cannot be listed (or + * `.basou/sessions` is refused, being a symlink or not a directory). */ export type BoardTrail = { decisions_all: number | null; @@ -59,7 +59,6 @@ export async function readDecisions(input: TrailInput): Promise if (!listed.ok) return { ok: false, reason: listed.reason }; const { dirs, notDirectories } = listed; for (const sessionId of dirs) { - const sessionDir = join(input.paths.sessions, sessionId); // A line that is not JSON may be a write in progress; one that is JSON // but not an event of the schema is complete, so it is a lost event // wherever it is. A retired zero-duration line is not a decision. @@ -71,8 +70,7 @@ export async function readDecisions(input: TrailInput): Promise input.onReplayWarning?.(warning, sessionId); }, }); - if (malformed.length > 0) - lost += await lostAmong(join(sessionDir, "events.jsonl"), malformed); + if (malformed.length > 0) lost += await lostAmong(input.paths, sessionId, malformed); for (const event of events) { if (event.type === "decision_recorded") { // One id is one decision: the earliest record of it stands. diff --git a/packages/core/src/events/chained-append.ts b/packages/core/src/events/chained-append.ts index c997564..7936a21 100644 --- a/packages/core/src/events/chained-append.ts +++ b/packages/core/src/events/chained-append.ts @@ -1,10 +1,9 @@ -import { appendFile, readFile } from "node:fs/promises"; +import { appendFile } from "node:fs/promises"; import { join } from "node:path"; -import { findErrorCode } from "../lib/error-codes.js"; import { type Event, EventSchema } from "../schemas/event.schema.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { acquireLock } from "../storage/lockfile.js"; -import { assertSessionDirSafe } from "../storage/session-dir.js"; +import { assertSessionDirSafe, readSessionFile } from "../storage/session-dir.js"; import { genesisHash, lineHash, serializeEventLine } from "./chain.js"; import { assertWritableEvent } from "./event-writer.js"; @@ -73,7 +72,9 @@ function carriesPrevHash(line: Buffer): boolean { * fragment. * * Throws the {@link assertSessionDirSafe} errors when the session's entry is - * a symlink or a file (it is not followed, so nothing is appended through it), + * a symlink or a file, and `SessionFileNotAFileError` when its + * `events.jsonl` is a symlink or anything but a regular file (neither is + * followed, so nothing is appended through it), * `Error("Failed to read events.jsonl")` for non-ENOENT I/O, * `Error("Unterminated final line in events.jsonl")` for a torn tail, and * `Error("events.jsonl is partially chained")` for a mixed first/last line. @@ -83,18 +84,9 @@ export async function inspectChainTail( sessionId: string, ): Promise { await assertSessionDirSafe(paths, sessionId); - const filePath = join(paths.sessions, sessionId, "events.jsonl"); - let raw: Buffer; - try { - raw = await readFile(filePath); - } catch (error: unknown) { - if (findErrorCode(error, "ENOENT")) { - // A not-yet-created log: the first append chains from the session genesis. - return { chained: true, head: genesisHash(sessionId), count: 0 }; - } - throw new Error("Failed to read events.jsonl", { cause: error }); - } - if (raw.length === 0) { + const raw = await readSessionFile(paths, sessionId, "events.jsonl"); + if (raw === null || raw.length === 0) { + // A not-yet-created or empty log: the first append chains from the session genesis. return { chained: true, head: genesisHash(sessionId), count: 0 }; } if (raw[raw.length - 1] !== 0x0a) { diff --git a/packages/core/src/events/event-replay.ts b/packages/core/src/events/event-replay.ts index a953066..18d0e12 100644 --- a/packages/core/src/events/event-replay.ts +++ b/packages/core/src/events/event-replay.ts @@ -1,7 +1,5 @@ import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; import { join } from "node:path"; -import { findErrorCode } from "../lib/error-codes.js"; import { type Event, EventSchema } from "../schemas/event.schema.js"; import { normalizeEventTimestamps } from "../schemas/iso-timestamp.js"; import { hasRetiredZeroDuration } from "../schemas/observed-duration.js"; @@ -9,7 +7,10 @@ import { SessionIdSchema } from "../schemas/shared.schema.js"; import type { BasouPaths } from "../storage/basou-dir.js"; import { lstatSessionEntry, + lstatSessionFile, type SessionEntryKind, + type SessionFileKind, + SessionFileNotAFileError, SessionNotADirectoryError, } from "../storage/session-dir.js"; @@ -56,6 +57,9 @@ export type ReplayOptions = { * be a symlink. * - No entry, ENOENT on events.jsonl, or an empty file: yields nothing * without warning. + * - An events.jsonl that is a symlink, whatever it points to, or anything but + * a regular file (a directory, a FIFO) is not followed: it throws + * {@link SessionFileNotAFileError} before anything is read. * - I/O error: throws `Error("Failed to read events.jsonl")` with the native * error attached as `cause`. The thrown message never embeds an absolute * path (pathless contract). @@ -93,14 +97,18 @@ export async function* replayEvents( } const filePath = join(paths.sessions, sessionId, "events.jsonl"); - // Probe existence first so ENOENT (= empty session) is silent while every - // other I/O failure surfaces as a single fixed-message error. + // Probe the file first, without following it, so ENOENT (= empty session) + // is silent, a symlink or anything but a regular file (a FIFO would block + // the read) is refused, and every other I/O failure surfaces as a single + // fixed-message error. + let file: SessionFileKind; try { - await stat(filePath); + file = await lstatSessionFile(paths, sessionId, "events.jsonl"); } catch (error: unknown) { - if (findErrorCode(error, "ENOENT")) return; throw new Error("Failed to read events.jsonl", { cause: error }); } + if (file === "missing") return; + if (file !== "file") throw new SessionFileNotAFileError(sessionId, "events.jsonl", file); let stream: ReturnType; try { diff --git a/packages/core/src/events/verify.test.ts b/packages/core/src/events/verify.test.ts index c0ba96c..2f85365 100644 --- a/packages/core/src/events/verify.test.ts +++ b/packages/core/src/events/verify.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { chmod, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -155,6 +156,175 @@ describe.skipIf(process.platform === "win32")( }, ); +/** Make a FIFO at `path`; reading it would block until a writer opens it. */ +function makeFifo(path: string): void { + execFileSync("mkfifo", [path]); +} + +// POSIX only: symlinks need privileges on Windows, and it has no mkfifo. +describe.skipIf(process.platform === "win32")( + "verifyEventsChain — a file in the session's directory that is not a regular file", + () => { + type Swap = (path: string, paths: BasouPaths) => Promise; + // Each replaces the file at `path` with something that is not a regular + // file; the first keeps the intact bytes behind a symlink, so a verifier + // that followed it would answer `verified`. + const swaps: [string, Swap][] = [ + [ + "a symlink to the intact file", + async (path, paths) => { + const outside = join(paths.root, "..", `outside-${path.split("/").pop()}`); + await cp(path, outside); + await rm(path); + await symlink(outside, path); + }, + ], + [ + "a dangling symlink", + async (path, paths) => { + await rm(path); + await symlink(join(paths.root, "..", "nowhere"), path); + }, + ], + [ + "a FIFO", + async (path) => { + await rm(path); + makeFifo(path); + }, + ], + [ + "a directory", + async (path) => { + await rm(path); + await mkdir(path); + }, + ], + ]; + + for (const [what, swap] of swaps) { + it(`reports events.jsonl as ${what} as events_not_a_file without reading it`, async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + await swap(fixture.eventsPath, paths); + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "tampered", + eventCount: 0, + reason: "events_not_a_file", + }); + }); + + it(`reports session.yaml as ${what} as yaml_not_a_file without reading it`, async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + await swap(fixture.yamlPath, paths); + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "tampered", + eventCount: 0, + reason: "yaml_not_a_file", + }); + }); + } + + // The log is not read, but a session.yaml that is a regular file is, so + // its version still decides as for any other row. + for (const [version, why] of [ + ["1.0.0", "a format major other than 0"], + ["0.99.0", "a newer 0.x.y"], + ] as const) { + it(`reports an events.jsonl that is not a regular file as unsupported under ${why}`, async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + const doc = parseYaml(await readFile(fixture.yamlPath, "utf8")) as Record; + await writeFile(fixture.yamlPath, stringifyYaml({ ...doc, schema_version: version })); + await rm(fixture.eventsPath); + await symlink(join(paths.root, "..", "nowhere"), fixture.eventsPath); + const verdict = await verifyEventsChain(paths, SES_ID); + expect(verdict).toMatchObject({ status: "unsupported", eventCount: 0 }); + expect(verdict.reason).toBeUndefined(); + }); + } + + it("marks session_yaml_invalid beside events_not_a_file when session.yaml does not load as a whole", async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + const doc = parseYaml(await readFile(fixture.yamlPath, "utf8")) as { + session: Record; + }; + // A field verify does not read, failing the full schema. + await writeFile( + fixture.yamlPath, + stringifyYaml({ ...doc, session: { ...doc.session, label: 42 } }), + ); + await rm(fixture.eventsPath); + makeFifo(fixture.eventsPath); + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "tampered", + eventCount: 0, + reason: "events_not_a_file", + sessionYamlInvalid: true, + }); + }); + + it("reports events.jsonl first when both files are not regular files", async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + for (const path of [fixture.yamlPath, fixture.eventsPath]) { + await rm(path); + makeFifo(path); + } + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "tampered", + eventCount: 0, + reason: "events_not_a_file", + }); + }); + + it("reports session.yaml that is not a regular file even with no events.jsonl", async () => { + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 0); + await rm(fixture.eventsPath); + await rm(fixture.yamlPath); + makeFifo(fixture.yamlPath); + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "tampered", + eventCount: 0, + reason: "yaml_not_a_file", + }); + }); + + it("control: the same session with both files regular is verified", async () => { + const paths = await setupPaths(); + await writeChainedSession(paths, SES_ID, 3); + expect(await verifyEventsChain(paths, SES_ID)).toEqual({ + status: "verified", + eventCount: 3, + }); + }); + + it("throws a pathless error when what events.jsonl is cannot be looked at", async () => { + // Without search permission on the session's directory, the lstat of + // its files fails. Skip as root, which chmod cannot block. + if (process.getuid?.() === 0) return; + const paths = await setupPaths(); + const fixture = await writeChainedSession(paths, SES_ID, 3); + await chmod(fixture.sessionDir, 0o600); + let captured: unknown; + try { + await verifyEventsChain(paths, SES_ID); + } catch (error: unknown) { + captured = error; + } finally { + await chmod(fixture.sessionDir, 0o755).catch(() => undefined); + } + expect(captured).toBeInstanceOf(Error); + expect((captured as Error).message).toBe("Failed to read events.jsonl"); + expect((captured as Error).message).not.toContain(paths.root); + expect(((captured as Error).cause as NodeJS.ErrnoException).code).toBe("EACCES"); + }); + }, +); + describe("verifyEventsChain — clean states", () => { it("verifies an intact chained session", async () => { const paths = await setupPaths(); diff --git a/packages/core/src/events/verify.ts b/packages/core/src/events/verify.ts index 169ac77..6b5644e 100644 --- a/packages/core/src/events/verify.ts +++ b/packages/core/src/events/verify.ts @@ -1,6 +1,4 @@ -import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { findErrorCode } from "../lib/error-codes.js"; import { normalizeSessionTimestamps } from "../schemas/iso-timestamp.js"; import { SESSION_SCHEMA_VERSION, @@ -12,7 +10,12 @@ import { } from "../schemas/session.schema.js"; import { SchemaVersionSchema } from "../schemas/shared.schema.js"; import type { BasouPaths } from "../storage/basou-dir.js"; -import { inspectSessionEntry } from "../storage/session-dir.js"; +import { + inspectSessionEntry, + inspectSessionFiles, + isUnfollowedFile, + readSessionFile, +} from "../storage/session-dir.js"; import { readYamlFile } from "../storage/yaml-store.js"; import { genesisHash, lineHash } from "./chain.js"; @@ -122,7 +125,21 @@ export type ChainBreakReason = * The entry named as the session is neither a directory nor a symlink — a * file, for instance. Nothing is read and `eventCount` is 0. */ - | "not_a_directory"; + | "not_a_directory" + /** + * The session's `events.jsonl` is a symlink, whatever it points to, or + * anything but a regular file (a directory, a FIFO). It is not followed, so + * the log is not read and `eventCount` is 0. A `session.yaml` that is a + * regular file is still read, and its version can make the row + * `unsupported` instead. + */ + | "events_not_a_file" + /** + * The session's `session.yaml` is a symlink, whatever it points to, or + * anything but a regular file (a directory, a FIFO). Nothing is read and + * `eventCount` is 0. When both files are such, either reason may be given. + */ + | "yaml_not_a_file"; /** Result of {@link verifyEventsChain}. */ export type ChainVerdict = { @@ -214,15 +231,23 @@ type AnchorState = * * An entry at the session's name that is not a directory is `tampered` before * anything is read — `symlink` for a symlink, `not_a_directory` for anything - * else (a file) — and is never followed. + * else (a file) — and is never followed. So, inside a session's directory, is + * an `events.jsonl` or a `session.yaml` that is a symlink, whatever it points + * to, or anything but a regular file (a directory, a FIFO): `tampered` with + * `events_not_a_file` or `yaml_not_a_file`, and the log is not read. When + * only `events.jsonl` is such, a `session.yaml` that is a regular file is read + * as usual, so its version still decides: `unsupported` at a format major + * other than 0 or a newer 0.x.y, and `sessionYamlInvalid` when it does not + * load as a whole. When `session.yaml` is such, nothing is read. * - * Throws `Error("Failed to read events.jsonl")`, or an error naming the - * session for the session directory or `session.yaml` (`Failed to read - * session.yaml of `), only for non-ENOENT I/O failures (EACCES etc.) — an - * unreadable file is an environment problem, not a verdict. A - * `.basou/sessions` that is a symlink or not a directory throws too (the - * `assertSessionStoreSafe` errors): no session in it is judged. So does a - * `sessionId` that is not a session id (`"Invalid session id"`). + * Throws `Error("Failed to read events.jsonl")`, `Error("Failed to read + * session.yaml")` when what that file is cannot be looked at, or an error + * naming the session for the session directory or for reading `session.yaml` + * (`Failed to read session.yaml of `), only for non-ENOENT I/O failures + * (EACCES etc.) — an unreadable file is an environment problem, not a + * verdict. A `.basou/sessions` that is a symlink or not a directory throws + * too (the `assertSessionStoreSafe` errors): no session in it is judged. So + * does a `sessionId` that is not a session id (`"Invalid session id"`). * * READ-ONLY and lock-free: a session being finalized concurrently can leave the * two files momentarily out of step (old events read before a finalize, new @@ -242,8 +267,6 @@ export async function verifyEventsChain( } async function verifyOnce(paths: BasouPaths, sessionId: string): Promise { - const sessionDir = join(paths.sessions, sessionId); - // A session is a directory inside the store. Anything else at its name is // not followed: reading through a symlink would verify files outside the // store, and an absent directory falls through to `empty` as before. @@ -252,17 +275,36 @@ async function verifyOnce(paths: BasouPaths, sessionId: string): Promise { }); it("skips an unreadable session from the integrity tally and still renders", async () => { + // chmod cannot block the superuser. + if (process.getuid?.() === 0) return; const paths = await setupPaths(); await placeSession(paths, { id: SES("0A1"), @@ -352,10 +354,14 @@ describe("report-renderer", () => { sourceKind: "terminal", events: startedLine(SES("0A1"), "E01", "2026-05-04T09:00:00.000Z"), }); - // A session whose events.jsonl is a DIRECTORY: readFile throws EISDIR (a + // A session whose events.jsonl cannot be read: readFile throws EACCES (a // non-ENOENT I/O error), which verifyEventsChain surfaces by throwing. - await placeSession(paths, { id: SES("0X1"), startedAt: "2026-05-04T08:00:00.000Z" }); - await mkdir(join(paths.sessions, SES("0X1"), "events.jsonl"), { recursive: true }); + await placeSession(paths, { + id: SES("0X1"), + startedAt: "2026-05-04T08:00:00.000Z", + events: startedLine(SES("0X1"), "E02", "2026-05-04T08:00:00.000Z"), + }); + await chmod(join(paths.sessions, SES("0X1"), "events.jsonl"), 0o000); const skips: Array<{ id: string; reason: string }> = []; const { data } = await renderReport({ @@ -375,6 +381,36 @@ describe("report-renderer", () => { ); }); + it("tallies a session whose events.jsonl is not a regular file as tampered, as verify does", async () => { + const paths = await setupPaths(); + await placeSession(paths, { + id: SES("0A1"), + startedAt: "2026-05-04T09:00:00.000Z", + sourceKind: "terminal", + events: startedLine(SES("0A1"), "E01", "2026-05-04T09:00:00.000Z"), + }); + // A DIRECTORY at events.jsonl is not followed: verify judges it, and the + // events it holds are unreadable to the rest of the report. + await placeSession(paths, { id: SES("0X1"), startedAt: "2026-05-04T08:00:00.000Z" }); + await mkdir(join(paths.sessions, SES("0X1"), "events.jsonl"), { recursive: true }); + + const skips: Array<{ id: string; reason: string }> = []; + const { data } = await renderReport({ + paths, + nowIso: NOW_ISO, + timeZone: TZ, + onSessionSkip: (id, reason) => skips.push({ id, reason }), + }); + + expect(data.sessions.total).toBe(2); + expect(data.integrity.total).toBe(2); + expect(data.integrity.unchained).toBe(1); + expect(data.integrity.tampered).toBe(1); + expect(skips.some((s) => s.id === SES("0X1") && s.reason === "events_jsonl_unreadable")).toBe( + true, + ); + }); + it("clamps a clock-skewed session so the period window is not reversed", async () => { const paths = await setupPaths(); await placeSession(paths, { diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts index 0aad61f..35b5046 100644 --- a/packages/core/src/storage/index.ts +++ b/packages/core/src/storage/index.ts @@ -41,11 +41,13 @@ export { portfolioPathExists, portfolioPathInitialized, } from "./portfolio-config.js"; -export type { SessionEntryKind } from "./session-dir.js"; +export type { SessionEntryKind, SessionFileKind, SessionFileName } from "./session-dir.js"; export { assertSessionDirSafe, assertSessionStoreSafe, inspectSessionEntry, + lstatSessionFile, + SessionFileNotAFileError, SessionNotADirectoryError, } from "./session-dir.js"; export type { diff --git a/packages/core/src/storage/session-dir.test.ts b/packages/core/src/storage/session-dir.test.ts index 647ba6d..2666707 100644 --- a/packages/core/src/storage/session-dir.test.ts +++ b/packages/core/src/storage/session-dir.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,6 +19,9 @@ import { assertSessionDirSafe, assertSessionStoreSafe, inspectSessionEntry, + lstatSessionFile, + type SessionFileName, + SessionFileNotAFileError, SessionNotADirectoryError, } from "./session-dir.js"; import { @@ -419,6 +423,278 @@ describe.skipIf(process.platform === "win32")("a session named by id is not foll }); }); +const FILE_NOT_A_FILE = (file: string, id: string): string => + `${file} of session ${id} is not a regular file; a symlink or anything else there is not followed`; + +type FileSwap = "symlink" | "dangling" | "fifo" | "directory"; + +/** + * Replace `file` of session `id` (a directory in the store) with something + * that is not a regular file. A `symlink` points at an intact copy of the file + * outside the store, so a reader or writer that followed it would succeed; + * the copy's path is returned. A `fifo` would block a reader that opened it. + */ +async function swapSessionFile( + paths: BasouPaths, + id: string, + file: SessionFileName, + how: FileSwap, +): Promise { + const path = join(paths.sessions, id, file); + const outside = join(getWorkDir(), `outside-${id.slice(-3)}-${file}`); + await writeFile(outside, await readFile(path)); + await rm(path); + if (how === "symlink") await symlink(outside, path); + else if (how === "dangling") await symlink(join(getWorkDir(), "nowhere"), path); + else if (how === "fifo") execFileSync("mkfifo", [path]); + else await mkdir(path); + return outside; +} + +// A Crockford base32 code for each swap, to build valid session ids from. +const CODE: Record = { + symlink: "SY", + dangling: "DG", + fifo: "FF", + directory: "DR", +}; + +const SWAPS: [FileSwap, "symlink" | "not_a_file"][] = [ + ["symlink", "symlink"], + ["dangling", "symlink"], + ["fifo", "not_a_file"], + ["directory", "not_a_file"], +]; + +// POSIX only: symlinks need privileges on Windows, and it has no mkfifo. +describe.skipIf(process.platform === "win32")( + "a session file that is not a regular file is not followed", + () => { + it("lstatSessionFile classifies a session file without following it", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + for (const [how] of SWAPS) { + const id = SES(`${CODE[how]}1`); + await writeSession(join(paths.sessions, id), id, "running"); + await swapSessionFile(paths, id, "events.jsonl", how); + } + await writeSession(join(paths.sessions, REAL), REAL, "running"); + await rm(join(paths.sessions, REAL, "session.yaml")); + expect(await lstatSessionFile(paths, REAL, "events.jsonl")).toBe("file"); + expect(await lstatSessionFile(paths, REAL, "session.yaml")).toBe("missing"); + expect(await lstatSessionFile(paths, SES("SY1"), "events.jsonl")).toBe("symlink"); + expect(await lstatSessionFile(paths, SES("DG1"), "events.jsonl")).toBe("symlink"); + expect(await lstatSessionFile(paths, SES("FF1"), "events.jsonl")).toBe("not_a_file"); + expect(await lstatSessionFile(paths, SES("DR1"), "events.jsonl")).toBe("not_a_file"); + }); + + it("the event readers refuse an events.jsonl that is not a regular file and read nothing", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + for (const [how, kind] of SWAPS) { + const id = SES(`${CODE[how]}2`); + await writeSession(join(paths.sessions, id), id, "running"); + await swapSessionFile(paths, id, "events.jsonl", how); + const eager = await readAllEvents(paths, id).catch((error: unknown) => error); + expect(eager).toBeInstanceOf(SessionFileNotAFileError); + expect(eager).toMatchObject({ + name: "SessionFileNotAFileError", + sessionId: id, + file: "events.jsonl", + kind, + message: FILE_NOT_A_FILE("events.jsonl", id), + }); + const streamed = await drain(paths, id); + expect(streamed.yielded).toEqual([]); + expect(streamed.error).toBeInstanceOf(SessionFileNotAFileError); + } + }); + + it("control: the event readers read a regular events.jsonl and nothing for a missing one", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + await writeSession(join(paths.sessions, REAL), REAL, "running"); + expect((await readAllEvents(paths, REAL)).map((ev) => ev.session_id)).toEqual([REAL]); + await rm(join(paths.sessions, REAL, "events.jsonl")); + expect(await readAllEvents(paths, REAL)).toEqual([]); + }); + + it("readSessionYaml refuses a session.yaml that is not a regular file", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + for (const [how, kind] of SWAPS) { + const id = SES(`${CODE[how]}3`); + await writeSession(join(paths.sessions, id), id, "running"); + await swapSessionFile(paths, id, "session.yaml", how); + const error = await readSessionYaml(paths, id).catch((e: unknown) => e); + expect(error).toBeInstanceOf(SessionFileNotAFileError); + expect(error).toMatchObject({ + sessionId: id, + file: "session.yaml", + kind, + message: FILE_NOT_A_FILE("session.yaml", id), + }); + } + }); + + it("a listing leaves out a session whose session.yaml is not a regular file and keeps one whose events.jsonl is not", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + const yamlLinked = SES("YM1"); + const eventsLinked = SES("EV1"); + await writeSession(join(paths.sessions, REAL), REAL, "running"); + await writeSession(join(paths.sessions, yamlLinked), yamlLinked, "running"); + await writeSession(join(paths.sessions, eventsLinked), eventsLinked, "running"); + await swapSessionFile(paths, yamlLinked, "session.yaml", "symlink"); + await swapSessionFile(paths, eventsLinked, "events.jsonl", "fifo"); + const skips: string[] = []; + const entries = await loadSessionEntries(paths, { + now: new Date("2026-05-08T12:00:00+09:00"), + onSkip: (sid, reason) => skips.push(`${sid}:${reason}`), + }); + expect(entries.map((e) => e.sessionId)).toEqual([eventsLinked, REAL]); + expect(skips.sort()).toEqual( + [`${yamlLinked}:session_yaml_not_a_file`, `${eventsLinked}:events_jsonl_unreadable`].sort(), + ); + }); + + it("an append, a note, a task attach and a finalize write nothing through an events.jsonl that is not a regular file", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + for (const how of ["symlink", "fifo"] as const) { + const id = SES(`${CODE[how]}4`); + await writeSession(join(paths.sessions, id), id, "running"); + const outside = await swapSessionFile(paths, id, "events.jsonl", how); + const before = await readFile(outside, "utf8"); + const refusal = FILE_NOT_A_FILE("events.jsonl", id); + await expect( + appendChainedEvent(paths, id, noteEvent(id, "evt_01HXABCDEF1234567890ABCEV6")), + ).rejects.toThrow(refusal); + await expect( + appendEventToExistingSession({ + paths, + sessionId: id, + eventBuilder: (eventId) => noteEvent(id, eventId), + }), + ).rejects.toThrow(refusal); + await expect( + createTaskWithEvent({ + mode: "attach", + paths, + occurredAt: "2026-05-08T12:00:00+09:00", + sessionId: id, + taskId: TASK_ID, + title: "attached", + initialStatus: "planned", + description: "", + }), + ).rejects.toThrow(refusal); + await expect( + finalizeSessionYaml(paths, id, (s) => { + s.session.status = "completed"; + }), + ).rejects.toThrow(refusal); + expect(await readFile(outside, "utf8")).toBe(before); + // The session is left as it was: still running, with no anchor. + expect((await readSessionYaml(paths, id)).session).toMatchObject({ status: "running" }); + expect((await readSessionYaml(paths, id)).session.integrity).toBeUndefined(); + } + expect(await readdir(paths.tasks)).not.toContain(`${TASK_ID}.md`); + }); + + it("a note, a task attach and a finalize write nothing through a session.yaml that is not a regular file", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + await writeSession(join(paths.sessions, LINKED), LINKED, "running"); + const outside = await swapSessionFile(paths, LINKED, "session.yaml", "symlink"); + const before = await snapshot(join(paths.sessions, LINKED)); + const outsideBefore = await readFile(outside, "utf8"); + const refusal = FILE_NOT_A_FILE("session.yaml", LINKED); + await expect( + appendEventToExistingSession({ + paths, + sessionId: LINKED, + eventBuilder: (eventId) => noteEvent(LINKED, eventId), + }), + ).rejects.toThrow(refusal); + await expect( + createTaskWithEvent({ + mode: "attach", + paths, + occurredAt: "2026-05-08T12:00:00+09:00", + sessionId: LINKED, + taskId: TASK_ID, + title: "attached", + initialStatus: "planned", + description: "", + }), + ).rejects.toThrow(refusal); + await expect( + finalizeSessionYaml(paths, LINKED, (s) => { + s.session.status = "completed"; + }), + ).rejects.toThrow(refusal); + expect(await readFile(outside, "utf8")).toBe(outsideBefore); + expect(await snapshot(join(paths.sessions, LINKED))).toEqual(before); + expect(await readdir(paths.tasks)).not.toContain(`${TASK_ID}.md`); + }); + + it("rechain skips it as events_not_a_file / yaml_not_a_file and rewrites nothing", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + const eventsLinked = SES("EV5"); + const yamlLinked = SES("YM5"); + await writeSession(join(paths.sessions, eventsLinked), eventsLinked, "imported"); + await writeSession(join(paths.sessions, yamlLinked), yamlLinked, "imported"); + const outsideEvents = await swapSessionFile(paths, eventsLinked, "events.jsonl", "symlink"); + const outsideYaml = await swapSessionFile(paths, yamlLinked, "session.yaml", "symlink"); + const before = [await readFile(outsideEvents, "utf8"), await readFile(outsideYaml, "utf8")]; + for (const dryRun of [true, false]) { + expect(await rechainSessionInPlace(paths, eventsLinked, { dryRun })).toEqual({ + status: "skipped", + reason: "events_not_a_file", + }); + expect(await rechainSessionInPlace(paths, yamlLinked, { dryRun })).toEqual({ + status: "skipped", + reason: "yaml_not_a_file", + }); + } + expect([await readFile(outsideEvents, "utf8"), await readFile(outsideYaml, "utf8")]).toEqual( + before, + ); + // The same sessions with their files back in place are rechained. + for (const [id, file, outside] of [ + [eventsLinked, "events.jsonl", outsideEvents], + [yamlLinked, "session.yaml", outsideYaml], + ] as const) { + await rm(join(paths.sessions, id, file)); + await writeFile(join(paths.sessions, id, file), await readFile(outside)); + expect(await rechainSessionInPlace(paths, id)).toEqual({ + status: "rechained", + eventCount: 1, + }); + } + }); + + it("an in-place re-import refuses it as a broken prior and rewrites nothing", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + for (const file of ["events.jsonl", "session.yaml"] as const) { + const id = SES(file === "events.jsonl" ? "EV6" : "YM6"); + await writeSession(join(paths.sessions, id), id, "imported"); + const outside = await swapSessionFile(paths, id, file, "symlink"); + const before = await readFile(outside, "utf8"); + expect(await reimportPreservingId(paths, makeManifest(), id, importPayload(id))).toEqual({ + status: "skipped", + reason: "prior_chain_broken", + }); + expect(await readFile(outside, "utf8")).toBe(before); + expect(await lstatSessionFile(paths, id, file)).toBe("symlink"); + } + }); + + it("classifySuspect refuses a running session whose events.jsonl is a FIFO without blocking", async () => { + const paths = await ensureBasouDirectory(getWorkDir()); + await writeSession(join(paths.sessions, REAL), REAL, "running"); + await swapSessionFile(paths, REAL, "events.jsonl", "fifo"); + await expect( + classifySuspect(paths, REAL, sessionDocument(REAL, "running"), new Date()), + ).rejects.toThrow(FILE_NOT_A_FILE("events.jsonl", REAL)); + }); + }, +); + /** The fixture's session document, as a reader would return it. */ function sessionDocument(id: string, status: Status): Session { return SessionSchema.parse(normalizeSessionTimestamps(parse(sessionYaml(id, status)))); diff --git a/packages/core/src/storage/session-dir.ts b/packages/core/src/storage/session-dir.ts index b904a63..8c7eb91 100644 --- a/packages/core/src/storage/session-dir.ts +++ b/packages/core/src/storage/session-dir.ts @@ -1,4 +1,4 @@ -import { lstat } from "node:fs/promises"; +import { lstat, readFile } from "node:fs/promises"; import { join } from "node:path"; import { findErrorCode } from "../lib/error-codes.js"; import { SessionIdSchema } from "../schemas/shared.schema.js"; @@ -125,3 +125,142 @@ export async function assertSessionDirSafe(paths: BasouPaths, sessionId: string) throw new SessionNotADirectoryError(sessionId, kind); } } + +/** A file basou keeps in a session's directory and reads back. */ +export type SessionFileName = "events.jsonl" | "session.yaml"; + +/** + * What is at a session file's name, judged without following symlinks: + * + * - `file` — a regular file. + * - `symlink` — a symlink, whatever it points to. + * - `not_a_file` — anything else: a directory, a FIFO, a socket, a device. + * - `missing` — nothing. + */ +export type SessionFileKind = "file" | "symlink" | "not_a_file" | "missing"; + +/** + * Thrown where a session's `events.jsonl` or `session.yaml` is read and the + * entry at that name is not a regular file: a symlink, whatever it points to, + * or anything else (a directory, a FIFO). It is not followed, so nothing is + * read from outside the store and no read waits on a FIFO. `file` says which + * file, and `kind` what is there, as {@link lstatSessionFile} classifies it. + * + * The message is ` of session is not a regular file; a symlink or + * anything else there is not followed`. Tell this refusal apart by its class, + * not by the message. + */ +export class SessionFileNotAFileError extends Error { + readonly sessionId: string; + readonly file: SessionFileName; + readonly kind: "symlink" | "not_a_file"; + + constructor(sessionId: string, file: SessionFileName, kind: "symlink" | "not_a_file") { + super( + `${file} of session ${sessionId} is not a regular file; a symlink or anything else there is not followed`, + ); + this.name = "SessionFileNotAFileError"; + this.sessionId = sessionId; + this.file = file; + this.kind = kind; + } +} + +/** + * Classify `//` without following it (see + * {@link SessionFileKind}), for a session whose entry the caller has already + * found to be a directory. Rethrows an lstat failure other than ENOENT as it + * is, for the caller to wrap in its own fixed message. + * + * Like the checks of the session's entry, this detects a file already + * swapped; it does not race-proof the filesystem. + */ +export async function lstatSessionFile( + paths: BasouPaths, + sessionId: string, + file: SessionFileName, +): Promise { + try { + const entry = await lstat(join(paths.sessions, sessionId, file)); + if (entry.isSymbolicLink()) return "symlink"; + return entry.isFile() ? "file" : "not_a_file"; + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) return "missing"; + throw error; + } +} + +/** + * What is at each of a session's files (see {@link SessionFileKind}), for a + * session whose entry the caller has already found to be a directory. + * + * Throws `Error("Failed to read ", { cause })` on an lstat failure + * other than ENOENT. + */ +export async function inspectSessionFiles( + paths: BasouPaths, + sessionId: string, +): Promise> { + const kinds = {} as Record; + for (const file of ["events.jsonl", "session.yaml"] as const) { + try { + kinds[file] = await lstatSessionFile(paths, sessionId, file); + } catch (error: unknown) { + throw new Error(`Failed to read ${file}`, { cause: error }); + } + } + return kinds; +} + +/** Whether a {@link SessionFileKind} is one that is not followed. */ +export function isUnfollowedFile(kind: SessionFileKind): kind is "symlink" | "not_a_file" { + return kind === "symlink" || kind === "not_a_file"; +} + +/** + * The first of a session's files that is there but is not a regular file, + * `events.jsonl` before `session.yaml`, or `null` when neither is. For a + * session whose entry the caller has already found to be a directory. + * + * Throws the {@link inspectSessionFiles} errors. + */ +export async function findUnfollowedSessionFile( + paths: BasouPaths, + sessionId: string, +): Promise<{ file: SessionFileName; kind: "symlink" | "not_a_file" } | null> { + const kinds = await inspectSessionFiles(paths, sessionId); + for (const file of ["events.jsonl", "session.yaml"] as const) { + const kind = kinds[file]; + if (isUnfollowedFile(kind)) return { file, kind }; + } + return null; +} + +/** + * Read the bytes of a session's `file` when it is a regular file, or `null` + * when there is none, for a session whose entry the caller has already found + * to be a directory. The file is not followed: a symlink or anything else + * there throws {@link SessionFileNotAFileError} before anything is read. + * + * Throws `Error("Failed to read ", { cause })` on any other failure. + */ +export async function readSessionFile( + paths: BasouPaths, + sessionId: string, + file: SessionFileName, +): Promise { + let kind: SessionFileKind; + try { + kind = await lstatSessionFile(paths, sessionId, file); + } catch (error: unknown) { + throw new Error(`Failed to read ${file}`, { cause: error }); + } + if (kind === "missing") return null; + if (kind !== "file") throw new SessionFileNotAFileError(sessionId, file, kind); + try { + return await readFile(join(paths.sessions, sessionId, file)); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) return null; + throw new Error(`Failed to read ${file}`, { cause: error }); + } +} diff --git a/packages/core/src/storage/session-import.ts b/packages/core/src/storage/session-import.ts index 334cdb3..50768b0 100644 --- a/packages/core/src/storage/session-import.ts +++ b/packages/core/src/storage/session-import.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { chainRawJsonLines } from "../events/chain.js"; @@ -21,7 +21,14 @@ import { TaskIdSchema } from "../schemas/shared.schema.js"; import { atomicReplace } from "./atomic.js"; import type { BasouPaths } from "./basou-dir.js"; import { acquireLock } from "./lockfile.js"; -import { assertSessionStoreSafe, inspectSessionEntry } from "./session-dir.js"; +import { + assertSessionStoreSafe, + findUnfollowedSessionFile, + inspectSessionEntry, + readSessionFile, + type SessionFileName, + SessionFileNotAFileError, +} from "./session-dir.js"; import { readSessionYaml } from "./sessions.js"; import { enumerateTaskIds } from "./tasks.js"; import { linkYamlFile, overwriteYamlFile } from "./yaml-store.js"; @@ -599,14 +606,7 @@ export async function reimportPreservingId( // `priorEvents` here instead would write an UNCHAINED file (the parsed // events lost their byte-exact form), contradicting the prior anchor. const eventsPath = join(sessionDir, "events.jsonl"); - let priorEventsRaw: Buffer | null = null; - try { - priorEventsRaw = await readFile(eventsPath); - } catch (error: unknown) { - if (!findErrorCode(error, "ENOENT")) { - throw new Error("Failed to read events.jsonl", { cause: error }); - } - } + const priorEventsRaw = await readSessionFile(paths, priorSessionId, "events.jsonl"); const chainResult = await writeEventsBulk(sessionDir, mergedEvents, { chain: true }); try { @@ -677,6 +677,10 @@ export type RechainResult = // `symlink` / `not_a_directory`: the entry named as the session is a // symlink or a file; it is not followed, so nothing is read or // rewritten (`basou verify` reports it `tampered`). + // `events_not_a_file` / `yaml_not_a_file`: the session's events.jsonl + // or session.yaml is a symlink or anything but a regular file; it is + // not followed either, and `basou verify` reports it `tampered` with + // the same reason. reason: | "already_chained" | "empty" @@ -687,9 +691,17 @@ export type RechainResult = | "yaml_missing" | "yaml_unreadable" | "symlink" - | "not_a_directory"; + | "not_a_directory" + | "events_not_a_file" + | "yaml_not_a_file"; }; +// The rechain skip reason for a session file that is not followed, the reason +// `basou verify` gives it. +function unfollowedReason(file: SessionFileName): "events_not_a_file" | "yaml_not_a_file" { + return file === "events.jsonl" ? "events_not_a_file" : "yaml_not_a_file"; +} + /** * Add the tamper-evidence hash chain, IN PLACE, to an imported session that * was written before chaining existed (or whose chain was legitimately never @@ -721,6 +733,8 @@ export async function rechainSessionInPlace( if (entry === "symlink" || entry === "not_a_directory") { return { status: "skipped", reason: entry }; } + const unfollowed = await findUnfollowedSessionFile(paths, sessionId); + if (unfollowed !== null) return { status: "skipped", reason: unfollowedReason(unfollowed.file) }; const sessionDir = join(paths.sessions, sessionId); // The CLI surfaces per-session error messages verbatim. acquireLock's are // pathless ("Lock is held by another process", "Failed to acquire lock", or @@ -732,6 +746,9 @@ export async function rechainSessionInPlace( try { record = await readSessionYaml(paths, sessionId); } catch (error: unknown) { + if (error instanceof SessionFileNotAFileError) { + return { status: "skipped", reason: unfollowedReason(error.file) }; + } if (error instanceof Error && error.message === "YAML file not found") { return { status: "skipped", reason: "yaml_missing" }; } @@ -761,12 +778,17 @@ export async function rechainSessionInPlace( // escapes would otherwise be silently rewritten). Every line a basou // writer produced satisfies both. const eventsPath = join(sessionDir, "events.jsonl"); - let priorRaw: Buffer; + let priorRaw: Buffer | null; try { - priorRaw = await readFile(eventsPath); + priorRaw = await readSessionFile(paths, sessionId, "events.jsonl"); } catch (error: unknown) { - throw new Error("Failed to read events.jsonl", { cause: error }); + if (error instanceof SessionFileNotAFileError) { + return { status: "skipped", reason: unfollowedReason(error.file) }; + } + throw error; } + // The verdict gate passed `unchained`, which a missing log never is. + if (priorRaw === null) throw new Error("Failed to read events.jsonl"); if (priorRaw.length === 0 || priorRaw[priorRaw.length - 1] !== 0x0a) { return { status: "skipped", reason: "events_unreadable" }; } diff --git a/packages/core/src/storage/sessions.test.ts b/packages/core/src/storage/sessions.test.ts index aac4638..18e85b9 100644 --- a/packages/core/src/storage/sessions.test.ts +++ b/packages/core/src/storage/sessions.test.ts @@ -404,8 +404,9 @@ describe("storage/sessions", () => { const paths = await setupPaths(); const id = SES("XAC"); await placeSession(paths, { id, status: "running" }); - // Replace events.jsonl with a directory so createReadStream fails with - // EISDIR. This is more portable than chmod-based EACCES across CI. + // Replace events.jsonl with a directory, which is not a regular file, so + // the read is refused. This is more portable than chmod-based EACCES + // across CI. await mkdir(join(paths.sessions, id, "events.jsonl"), { recursive: true }); const skips: Array<{ sid: string; reason: SessionSkipReason }> = []; const entries = await loadSessionEntries(paths, { diff --git a/packages/core/src/storage/sessions.ts b/packages/core/src/storage/sessions.ts index 836ca5a..b2cf031 100644 --- a/packages/core/src/storage/sessions.ts +++ b/packages/core/src/storage/sessions.ts @@ -9,7 +9,13 @@ import { type Session, SessionSchema } from "../schemas/session.schema.js"; import { SessionIdSchema } from "../schemas/shared.schema.js"; import type { BasouPaths } from "./basou-dir.js"; import { acquireLock } from "./lockfile.js"; -import { assertSessionDirSafe, assertSessionStoreSafe } from "./session-dir.js"; +import { + assertSessionDirSafe, + assertSessionStoreSafe, + lstatSessionFile, + type SessionFileKind, + SessionFileNotAFileError, +} from "./session-dir.js"; import { overwriteYamlFile, readYamlFile } from "./yaml-store.js"; /** @@ -56,12 +62,17 @@ export type SessionEntry = { * - `session_dir_not_directory`: an entry named as a session id is not a * directory (a symlink, a file). It is not followed, and is omitted from the * result (see {@link enumerateSessionEntries}). + * - `session_yaml_not_a_file`: the session's `session.yaml` is a symlink or + * anything but a regular file. It is not followed, and the entry is omitted + * from the result. An `events.jsonl` that is not a regular file is + * `events_jsonl_unreadable`, as for any other failure to read it. */ export type SessionSkipReason = | "session_yaml_missing" | "session_yaml_invalid" | "events_jsonl_unreadable" - | "session_dir_not_directory"; + | "session_dir_not_directory" + | "session_yaml_not_a_file"; export type LoadSessionEntriesOptions = { /** @@ -173,6 +184,9 @@ export async function enumerateSessionDirs(paths: BasouPaths): Promise * * - Throws the {@link assertSessionDirSafe} errors when the session's entry * (or `.basou/sessions`) is a symlink or a file: it is not followed. + * - Throws {@link SessionFileNotAFileError} when `session.yaml` itself is a + * symlink, whatever it points to, or anything but a regular file (a + * directory, a FIFO): it is not followed either. * - Re-throws the yaml-store fixed-message `"YAML file not found"` for * ENOENT so the caller can branch on it. * - Throws `Error("Failed to read session.yaml", { cause })` for parse @@ -185,8 +199,18 @@ export async function readSessionYaml(paths: BasouPaths, sessionId: string): Pro } // The read itself, for a session the caller has already found to be a -// directory (a listing classified it from its dirent). +// directory (a listing classified it from its dirent). The file is not +// followed: a symlink or anything but a regular file there is refused. async function readSessionYamlFile(paths: BasouPaths, sessionId: string): Promise { + let kind: SessionFileKind; + try { + kind = await lstatSessionFile(paths, sessionId, "session.yaml"); + } catch (error: unknown) { + throw new Error("Failed to read session.yaml", { cause: error }); + } + if (kind === "symlink" || kind === "not_a_file") { + throw new SessionFileNotAFileError(sessionId, "session.yaml", kind); + } const filePath = join(paths.sessions, sessionId, "session.yaml"); let raw: unknown; try { @@ -323,6 +347,8 @@ async function classifyRunningSession( * so callers can render the session row plus a CLI-side warning. * - `session_dir_not_directory`: an entry named as a session id that is not a * directory; it is not read. + * - `session_yaml_not_a_file`: a `session.yaml` that is a symlink or anything + * but a regular file; it is not read, and the entry is omitted. * * The local store (`host: null`) is refused when `.basou/sessions` is a * symlink or a file ({@link assertSessionStoreSafe}); a federated mirror is @@ -348,7 +374,9 @@ async function loadEntriesFromRoot( try { session = await readSessionYamlFile(paths, sid); } catch (error: unknown) { - if (error instanceof Error && error.message === "YAML file not found") { + if (error instanceof SessionFileNotAFileError) { + options.onSkip?.(sid, "session_yaml_not_a_file"); + } else if (error instanceof Error && error.message === "YAML file not found") { options.onSkip?.(sid, "session_yaml_missing"); } else { options.onSkip?.(sid, "session_yaml_invalid"); diff --git a/packages/core/src/storage/tasks.test.ts b/packages/core/src/storage/tasks.test.ts index e10c1de..36ef254 100644 --- a/packages/core/src/storage/tasks.test.ts +++ b/packages/core/src/storage/tasks.test.ts @@ -2027,6 +2027,32 @@ describe("refreshTaskLinkedSessions", () => { }, ); + it.skipIf(process.platform === "win32")( + "keeps a link to a session whose session.yaml is a symlink, whose task_id it cannot read", + async () => { + const { refreshTaskLinkedSessions } = await import("./tasks.js"); + const paths = await setupPaths(); + await placeSessionWithTaskId(paths, SES_ID_RUNNING, TASK_ID_A); + // The yaml behind the symlink names another task: a refresh that + // followed it would remove the link. + await placeSessionWithTaskId(paths, SES_ID_OTHER, TASK_ID_B); + const yamlPath = join(paths.sessions, SES_ID_OTHER, "session.yaml"); + const outside = join(getWorkDir(), "moved-session.yaml"); + await rename(yamlPath, outside); + await symlink(outside, yamlPath); + await placeTaskWithLinkedSessions(paths, [SES_ID_RUNNING, SES_ID_OTHER]); + const result = await refreshTaskLinkedSessions(paths, makeManifest(), { + taskId: TASK_ID_A, + occurredAt: OCC_AT, + workingDirectory: getWorkDir(), + write: true, + }); + expect(result.removedLinkedSessions).toEqual([]); + const doc = await readTaskFile(paths, TASK_ID_A); + expect(doc.task.task.linked_sessions).toContain(SES_ID_OTHER); + }, + ); + it("removes a snapshot entry whose session.yaml no longer links to the task", async () => { const { refreshTaskLinkedSessions } = await import("./tasks.js"); const paths = await setupPaths(); diff --git a/packages/core/src/storage/tasks.ts b/packages/core/src/storage/tasks.ts index ba47ba1..3ff5472 100644 --- a/packages/core/src/storage/tasks.ts +++ b/packages/core/src/storage/tasks.ts @@ -32,6 +32,7 @@ import { import { atomicCreate, atomicReplace } from "./atomic.js"; import type { BasouPaths } from "./basou-dir.js"; import { acquireLock, assertLockStoreSafe } from "./lockfile.js"; +import { SessionFileNotAFileError } from "./session-dir.js"; import { enumerateSessionEntries, readSessionYaml } from "./sessions.js"; import { assertStoreDirectorySafe } from "./store-dir.js"; import { readTaskIndex, rebuildTaskIndex, updateTaskIndex } from "./task-index.js"; @@ -2026,13 +2027,15 @@ type DetectedLinkageDelta = { // skipped so a single broken session does not abort the workspace-wide // refresh; surfacing those is the responsibility of the session-integrity // tooling. An entry that is not a directory (a symlink, a file) is not -// followed, so its `task_id` cannot be read; a link to one is kept as it is -// rather than removed. +// followed, nor is a session.yaml that is not a regular file, so its +// `task_id` cannot be read; a link to one is kept as it is rather than +// removed. async function detectLinkageDelta( paths: BasouPaths, task: Task["task"], ): Promise { const { dirs: sessionIds, notDirectories } = await enumerateSessionEntries(paths); + const unfollowed = [...notDirectories]; const reachable = new Set(); for (const sid of sessionIds) { try { @@ -2040,7 +2043,10 @@ async function detectLinkageDelta( if (doc.session.task_id === task.id) { reachable.add(sid); } - } catch { + } catch (error: unknown) { + // A session.yaml that is not a regular file is not followed, so its + // task_id cannot be read either; a link to it is kept as it is. + if (error instanceof SessionFileNotAFileError) unfollowed.push(sid); // Missing / malformed session.yaml — skip. Surfacing those is the // responsibility of session-integrity tooling, not the linkage-refresh // path; a single corrupt session.yaml must not abort the workspace @@ -2056,7 +2062,7 @@ async function detectLinkageDelta( finalSet.add(task.created_in_session); const currentSet = new Set(task.linked_sessions); - for (const sid of notDirectories) { + for (const sid of unfollowed) { if (currentSet.has(sid)) finalSet.add(sid); } const addedLinkedSessions: PrefixedId<"ses">[] = []; diff --git a/packages/sdk/src/workspace.test.ts b/packages/sdk/src/workspace.test.ts index 6c6fa5a..27bc0f7 100644 --- a/packages/sdk/src/workspace.test.ts +++ b/packages/sdk/src/workspace.test.ts @@ -264,6 +264,42 @@ describe("openWorkspace", () => { }, ); + it.skipIf(process.platform === "win32")( + "reads nothing through a session's events.jsonl or session.yaml that is a symlink", + async () => { + const root = await setupWorkspace(); + const sessions = join(root, ".basou", "sessions"); + // The intact files behind the links: a read that followed them would + // return the session and its events. + const diagnostics: string[] = []; + const events = join(sessions, SES_DONE, "events.jsonl"); + await rename(events, join(root, "moved-events.jsonl")); + await symlink(join(root, "moved-events.jsonl"), events); + const ws = await openWorkspace(root, { + onDiagnostic: (d) => diagnostics.push(`${d.id}:${d.message}`), + }); + // As for a session entry that is not a directory: nothing, reported. + expect(await ws.readEvents(SES_DONE)).toEqual([]); + const streamed: Event[] = []; + for await (const e of ws.streamEvents(SES_DONE)) streamed.push(e); + expect(streamed).toEqual([]); + // In the words stats() and the renderers use for the same log. + expect(diagnostics).toEqual([ + `${SES_DONE}:skipped: events_jsonl_unreadable`, + `${SES_DONE}:skipped: events_jsonl_unreadable`, + ]); + + await rm(events); + await rename(join(root, "moved-events.jsonl"), events); + const yaml = join(sessions, SES_DONE, "session.yaml"); + await rename(yaml, join(root, "moved-session.yaml")); + await symlink(join(root, "moved-session.yaml"), yaml); + expect(await ws.getSession(SES_DONE)).toBeNull(); + expect((await ws.listSessions()).map((s) => s.sessionId)).not.toContain(SES_DONE); + expect(diagnostics).toContain(`${SES_DONE}:skipped: session_yaml_not_a_file`); + }, + ); + it("reads a session's events eagerly and as a stream", async () => { const ws = await openWorkspace(await setupWorkspace()); const events = await ws.readEvents(SES_DONE); diff --git a/packages/sdk/src/workspace.ts b/packages/sdk/src/workspace.ts index 859b488..7c6b3e6 100644 --- a/packages/sdk/src/workspace.ts +++ b/packages/sdk/src/workspace.ts @@ -23,6 +23,7 @@ import { resolveSessionId, resolveTaskId, type SessionEntry, + SessionFileNotAFileError, SessionNotADirectoryError, type StatusSnapshot, type TaskDocument, @@ -127,13 +128,28 @@ export interface Workspace { /** A freshly computed workspace status snapshot (directory presence + manifest). */ status(): Promise; - /** Every session, ULID-ascending, each with its `suspect` classification. */ + /** + * Every session, ULID-ascending, each with its `suspect` classification. A + * session whose `session.yaml` is a symlink or anything but a regular file + * is not followed: it is left out and reported to `onDiagnostic`. + */ listSessions(): Promise; - /** One session by id / unique prefix, or `null` if no session matches. */ + /** + * One session by id / unique prefix, or `null` if no session matches, or if + * its `session.yaml` is a symlink or anything but a regular file. + */ getSession(idOrPrefix: string): Promise; - /** All events of a session, eagerly, ordered as written. Empty if no match. */ + /** + * All events of a session, eagerly, ordered as written. Empty if no match. + * Empty too when the session's `events.jsonl` is a symlink or anything but a + * regular file: it is not followed, and is reported to `onDiagnostic` as an + * unreadable log. + */ readEvents(idOrPrefix: string): Promise; - /** All events of a session as a lazy stream (for large logs). */ + /** + * All events of a session as a lazy stream (for large logs). Nothing when + * the session's `events.jsonl` is not followed, as for {@link readEvents}. + */ streamEvents(idOrPrefix: string): AsyncIterable; /** Every task (active + lazily-indexed), created-at ascending. */ @@ -262,14 +278,27 @@ export async function openWorkspace( readEvents: async (idOrPrefix) => { const id = await resolveSession(idOrPrefix); if (id === null) return []; - return readAllEvents(paths, id, { onWarning: (w) => onWarning(w, id) }); + try { + return await readAllEvents(paths, id, { onWarning: (w) => onWarning(w, id) }); + } catch (error) { + if (!(error instanceof SessionFileNotAFileError)) throw error; + onSkip(id, "events_jsonl_unreadable"); + return []; + } }, streamEvents: (idOrPrefix): AsyncIterable => { async function* iterate(): AsyncGenerator { const id = await resolveSession(idOrPrefix); if (id === null) return; - yield* replayEvents(paths, id, { onWarning: (w) => onWarning(w, id) }); + // The file is checked before the first event is read, so a refusal + // ends a stream that has yielded nothing. + try { + yield* replayEvents(paths, id, { onWarning: (w) => onWarning(w, id) }); + } catch (error) { + if (!(error instanceof SessionFileNotAFileError)) throw error; + onSkip(id, "events_jsonl_unreadable"); + } } return iterate(); },