diff --git a/Cargo.lock b/Cargo.lock index 618eeae..2658b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,7 @@ dependencies = [ name = "cli-master-core" version = "0.2.0" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "uuid", @@ -505,6 +506,7 @@ dependencies = [ "base64 0.22.1", "cli-master-agents", "cli-master-core", + "cli-master-file-metadata", "cli-master-git", "cli-master-session", "cli-master-storage", @@ -512,6 +514,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.20", "tokio", @@ -560,6 +563,13 @@ dependencies = [ "signal-hook", ] +[[package]] +name = "cli-master-file-metadata" +version = "0.2.0" +dependencies = [ + "tempfile", +] + [[package]] name = "cli-master-git" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 145602f..0ce1017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/daemon", "crates/e2e", "crates/fake-agent", + "crates/file-metadata", "crates/git", "crates/session", "crates/storage", diff --git a/apps/desktop/src/app/AppShell.test.tsx b/apps/desktop/src/app/AppShell.test.tsx index 209d24b..648099c 100644 --- a/apps/desktop/src/app/AppShell.test.tsx +++ b/apps/desktop/src/app/AppShell.test.tsx @@ -1430,6 +1430,7 @@ describe("AppShell canvas workflows", () => { sessionId: stoppedSession.id, path: stoppedSession.worktreePath, }); + let registeredWorktrees = [worktree]; const client = createMockIpcClient({ bootstrap: createBootstrap({ projects: [project], @@ -1438,7 +1439,7 @@ describe("AppShell canvas workflows", () => { worktrees: [worktree], }), handlers: { - listWorktrees: async () => [], + listWorktrees: async () => registeredWorktrees, stopSession: async () => ({ ...runningSession, status: "exited", @@ -1451,7 +1452,9 @@ describe("AppShell canvas workflows", () => { worktreeId: worktree.id, expiresAtMs: TEST_TIME + 60_000, }), - removeWorktree: async () => undefined, + removeWorktree: async () => { + registeredWorktrees = []; + }, }, }); const user = await renderApp(client); diff --git a/apps/desktop/src/app/features/organization/OrganizationPanel.test.tsx b/apps/desktop/src/app/features/organization/OrganizationPanel.test.tsx new file mode 100644 index 0000000..6818ce2 --- /dev/null +++ b/apps/desktop/src/app/features/organization/OrganizationPanel.test.tsx @@ -0,0 +1,193 @@ +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { IpcError } from "../../../ipc/client"; +import type { OrganizationEntry, OrganizationGetResponse, OrganizationTarget } from "../../../ipc/domain"; +import { organizationTargetKey } from "../../../ipc/organization-schema"; +import type { Session } from "../../../ipc/types"; +import { createMockIpcClient } from "../../../test/mockIpc"; +import { OrganizationPanel } from "./OrganizationPanel"; + +const project = { id: "project-a", name: "Project A" }; +const session: Pick = { id: "session-a", name: "Session A", status: "running" }; + +function defaults(target: OrganizationTarget): OrganizationEntry { + return { target, pinned: false, archived: false, workflow: target.kind === "project" ? null : "backlog", revision: 0, updatedAtMs: null }; +} + +/** Mirrors compare-and-save metadata semantics without any process operations. */ +function organizationClient() { + const entries = new Map(); + const client = createMockIpcClient({ handlers: { + getOrganization: async ({ targets }) => ({ entries: targets.map((target) => entries.get(organizationTargetKey(target)) ?? defaults(target)) }), + saveOrganization: async (input) => { + const key = organizationTargetKey(input.target); + const current = entries.get(key) ?? defaults(input.target); + if (input.expectedRevision !== current.revision) throw new IpcError({ code: "organization_conflict", message: "Organization changed in another window." }); + const saved = { target: input.target, pinned: input.pinned, archived: input.archived, workflow: input.workflow, revision: current.revision + 1, updatedAtMs: 100 }; + entries.set(key, saved); + return saved; + }, + } }); + return { client, entries }; +} + +function deferred() { + let settle: (value: T) => void = () => { throw new Error("Promise not initialized"); }; + const promise = new Promise((resolve) => { settle = resolve; }); + return { promise, resolve: settle }; +} + +describe("OrganizationPanel", () => { + it("skips empty selection and batches existing project/session defaults without writes", async () => { + const { client } = organizationClient(); + const view = render(); + expect(screen.getByText("Select a project or session to organize it.")).toBeVisible(); + expect(client.getOrganization).not.toHaveBeenCalled(); + view.rerender(); + await waitFor(() => expect(screen.getAllByRole("checkbox", { name: "Pinned" })[0]).toBeEnabled()); + expect(client.getOrganization).toHaveBeenCalledWith({ targets: [{ kind: "project", id: project.id }, { kind: "session", id: session.id }] }); + expect(screen.getByRole("combobox", { name: "Workflow" })).toHaveValue("backlog"); + expect(client.saveOrganization).not.toHaveBeenCalled(); + }); + + it("explicitly saves an archived active session and workflow while showing its unchanged process status", async () => { + const user = userEvent.setup(); + const { client } = organizationClient(); + const onChanged = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Archived" })).toBeEnabled()); + await user.click(screen.getByRole("checkbox", { name: "Pinned" })); + await user.click(screen.getByRole("checkbox", { name: "Archived" })); + await user.selectOptions(screen.getByRole("combobox", { name: "Workflow" }), "done"); + expect(screen.getByText(/this session continues running/)).toBeVisible(); + expect(screen.getByLabelText("Session status: Running")).toBeVisible(); + expect(client.saveOrganization).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Save organization" })); + expect(await screen.findByText("Organization saved locally.")).toBeVisible(); + expect(client.saveOrganization).toHaveBeenCalledWith({ target: { kind: "session", id: session.id }, expectedRevision: 0, pinned: true, archived: true, workflow: "done" }); + expect(onChanged).toHaveBeenCalledWith(expect.objectContaining({ revision: 1, workflow: "done", archived: true })); + expect(screen.getByLabelText("Session status: Running")).toBeVisible(); + expect(client.stopSession).not.toHaveBeenCalled(); + expect(client.startSession).not.toHaveBeenCalled(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + }); + + it("saves project flags with null workflow and preserves unsaved choices across project selection", async () => { + const user = userEvent.setup(); + const { client } = organizationClient(); + const view = render(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await user.click(screen.getByRole("checkbox", { name: "Pinned" })); + view.rerender(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + expect(screen.getByRole("checkbox", { name: "Pinned" })).not.toBeChecked(); + view.rerender(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeChecked(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Save organization" })); + expect(client.saveOrganization).toHaveBeenCalledWith({ target: { kind: "project", id: project.id }, expectedRevision: 0, pinned: true, archived: false, workflow: null }); + }); + + it("preserves workflow drafts across sessions and discards only the selected entity's choices", async () => { + const user = userEvent.setup(); + const { client } = organizationClient(); + const view = render(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeEnabled()); + await user.selectOptions(screen.getByRole("combobox"), "in_review"); + view.rerender(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeEnabled()); + await user.click(screen.getByRole("checkbox", { name: "Archived" })); + view.rerender(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeEnabled()); + expect(screen.getByRole("combobox")).toHaveValue("in_review"); + await user.click(screen.getByRole("button", { name: "Discard changes" })); + expect(screen.getByRole("combobox")).toHaveValue("backlog"); + expect(client.saveOrganization).not.toHaveBeenCalled(); + }); + + it("retains conflicting desired flags and requires explicit revision refresh before retrying", async () => { + const user = userEvent.setup(); + const { client, entries } = organizationClient(); + const target: OrganizationTarget = { kind: "session", id: session.id }; + render(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await user.click(screen.getByRole("checkbox", { name: "Pinned" })); + await user.selectOptions(screen.getByRole("combobox"), "blocked"); + entries.set(organizationTargetKey(target), { ...defaults(target), archived: true, revision: 1, updatedAtMs: 100 }); + await user.click(screen.getByRole("button", { name: "Save organization" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Your choices are preserved"); + expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Archived" })).not.toBeChecked(); + expect(screen.getByRole("combobox")).toHaveValue("blocked"); + expect(screen.getByRole("button", { name: "Save organization" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Refresh revision" })); + expect(await screen.findByText(/Latest revision loaded/)).toBeVisible(); + expect(screen.getByText("Pinned: No. Archived: Yes. Workflow: Backlog.")).toBeVisible(); + await waitFor(() => expect(screen.getByRole("button", { name: "Save organization" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Save organization" })); + expect(client.saveOrganization).toHaveBeenLastCalledWith({ target, expectedRevision: 1, pinned: true, archived: false, workflow: "blocked" }); + expect(await screen.findByText("Organization saved locally.")).toBeVisible(); + }); + + it("ignores old batch responses when project selection or connection changes", async () => { + const first = deferred(); + const second = deferred(); + const { client } = organizationClient(); + client.getOrganization.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const view = render(); + await waitFor(() => expect(client.getOrganization).toHaveBeenCalledTimes(1)); + view.rerender(); + await waitFor(() => expect(client.getOrganization).toHaveBeenCalledTimes(2)); + view.rerender(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await act(async () => { + first.resolve({ entries: [{ ...defaults({ kind: "project", id: project.id }), pinned: true, revision: 1, updatedAtMs: 100 }] }); + second.resolve({ entries: [{ ...defaults({ kind: "project", id: "project-b" }), pinned: true, revision: 1, updatedAtMs: 100 }] }); + }); + expect(screen.getByRole("checkbox", { name: "Pinned" })).not.toBeChecked(); + }); + + it("ignores a stale save response after reconnect and preserves the desired flags for revalidation", async () => { + const user = userEvent.setup(); + const pendingSave = deferred(); + const { client } = organizationClient(); + const onChanged = vi.fn(); + client.saveOrganization.mockReturnValueOnce(pendingSave.promise); + const view = render(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await user.click(screen.getByRole("checkbox", { name: "Pinned" })); + await user.click(screen.getByRole("button", { name: "Save organization" })); + view.rerender(); + await waitFor(() => expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await act(async () => pendingSave.resolve({ ...defaults({ kind: "project", id: project.id }), pinned: true, revision: 1, updatedAtMs: 100 })); + expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeChecked(); + expect(screen.queryByText("Organization saved locally.")).not.toBeInTheDocument(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it("rejects a batch for another target and leaves controls unavailable", async () => { + const { client } = organizationClient(); + client.getOrganization.mockResolvedValue({ entries: [defaults({ kind: "project", id: "another-project" })] }); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("invalid response"); + expect(screen.getByRole("checkbox", { name: "Pinned" })).toBeDisabled(); + }); + + it("prevents duplicate saves while a request is pending", async () => { + const user = userEvent.setup(); + const pendingSave = deferred(); + const { client } = organizationClient(); + client.saveOrganization.mockReturnValueOnce(pendingSave.promise); + render(); + const card = screen.getByRole("form", { name: project.name }); + await waitFor(() => expect(within(card).getByRole("checkbox", { name: "Pinned" })).toBeEnabled()); + await user.click(within(card).getByRole("checkbox", { name: "Pinned" })); + await user.dblClick(within(card).getByRole("button", { name: "Save organization" })); + expect(client.saveOrganization).toHaveBeenCalledOnce(); + expect(within(card).getByRole("checkbox", { name: "Pinned" })).toBeDisabled(); + await act(async () => pendingSave.resolve({ ...defaults({ kind: "project", id: project.id }), pinned: true, revision: 1, updatedAtMs: 100 })); + }); +}); diff --git a/apps/desktop/src/app/features/organization/OrganizationPanel.tsx b/apps/desktop/src/app/features/organization/OrganizationPanel.tsx new file mode 100644 index 0000000..ca98b04 --- /dev/null +++ b/apps/desktop/src/app/features/organization/OrganizationPanel.tsx @@ -0,0 +1,115 @@ +import { useId, type FormEvent } from "react"; + +import type { OrganizationTarget, OrganizationWorkflow } from "../../../ipc/domain"; +import { isOrganizationWorkflow, organizationTargetKey } from "../../../ipc/organization-schema"; +import type { SessionStatus } from "../../../ipc/types"; +import { StatusBadge, getSessionStatusLabel } from "../../components/StatusBadge"; +import { isLiveStatus } from "../../utils"; +import type { OrganizationDraft, OrganizationPanelProps } from "./organization-types"; +import { hasOrganizationChanges, useOrganization } from "./useOrganization"; +import "./organization.css"; + +/** Explicit organization controls; the host retains canvas selection and runtime ownership. */ +export function OrganizationPanel(props: OrganizationPanelProps) { + const organization = useOrganization(props); + const id = useId(); + const selected: { target: OrganizationTarget; name: string; status?: SessionStatus }[] = []; + if (props.currentProject) selected.push({ target: { kind: "project", id: props.currentProject.id }, name: props.currentProject.name }); + if (props.currentSession) selected.push({ target: { kind: "session", id: props.currentSession.id }, name: props.currentSession.name, status: props.currentSession.status }); + + return ( +
+
+

Organization

Pin, archive and track progress for the current selection.

+ +
+ {!selected.length ?

Select a project or session to organize it.

: null} + {organization.loading ?

Loading organization…

: null} + {organization.error ?

{organization.error}

: null} +
+ {selected.map(({ target, name, status }) => { + const key = organizationTargetKey(target); + return organization.update(target, patch)} + onDiscard={() => organization.discard(target)} + onSave={() => void organization.change(target, "saving")} + onRefreshRevision={() => void organization.change(target, "refreshing")} + />; + })} +
+
+ ); +} + +interface OrganizationCardProps { + readonly target: OrganizationTarget; + readonly name: string; + readonly status?: SessionStatus; + readonly draft?: OrganizationDraft; + readonly loading: boolean; + readonly operation?: "saving" | "refreshing"; + readonly onUpdate: (patch: Partial>) => void; + readonly onDiscard: () => void; + readonly onSave: () => void; + readonly onRefreshRevision: () => void; +} + +function OrganizationCard({ target, name, status, draft, loading, operation, onUpdate, onDiscard, onSave, onRefreshRevision }: OrganizationCardProps) { + const id = useId(); + const disabled = loading || Boolean(operation) || !draft; + const dirty = draft ? hasOrganizationChanges(draft) : false; + const kindLabel = target.kind === "project" ? "Project" : "Session"; + + function submit(event: FormEvent) { + event.preventDefault(); + if (!disabled && dirty && !draft?.needsRebase) onSave(); + } + + return ( +
+
+

{kindLabel}

{name}

+ {dirty ? Unsaved changes : null} +
+ {status ?

Daemon process:

: null} +
+ {kindLabel} organization + + + {target.kind === "session" ? : null} +
+ {target.kind === "session" ?

Workflow tracks your work separately from the daemon process.

: null} + {(draft?.archived || draft?.original.archived) && status && isLiveStatus(status) ?

The daemon reports {getSessionStatusLabel(status)}. Archiving changes organization only; this session continues running.

: null} + {(draft?.archived || draft?.original.archived) && status === "unknown" ?

Process status is unknown. Archiving does not stop a session.

: null} + {draft?.error ?
+

{draft.error}

+ {draft.needsRebase ?

Your choices are preserved. Refresh the revision, review the latest saved settings, and save again.

: null} +
: null} + {draft?.rebased ?
+ Latest saved settings +

Pinned: {draft.original.pinned ? "Yes" : "No"}. Archived: {draft.original.archived ? "Yes" : "No"}.{draft.original.workflow ? ` Workflow: ${workflowLabels[draft.original.workflow]}.` : ""}

+
: null} +

{operation === "saving" ? "Saving organization…" : operation === "refreshing" ? "Refreshing the saved revision…" : draft?.notice ?? ""}

+
+ + + +
+
+ ); +} + +const workflows: readonly OrganizationWorkflow[] = ["backlog", "in_progress", "in_review", "blocked", "done"]; +const workflowLabels: Readonly> = { backlog: "Backlog", in_progress: "In progress", in_review: "In review", blocked: "Blocked", done: "Done" }; diff --git a/apps/desktop/src/app/features/organization/index.ts b/apps/desktop/src/app/features/organization/index.ts new file mode 100644 index 0000000..e8f4758 --- /dev/null +++ b/apps/desktop/src/app/features/organization/index.ts @@ -0,0 +1,2 @@ +export { OrganizationPanel } from "./OrganizationPanel"; +export type { OrganizationPanelProps } from "./organization-types"; diff --git a/apps/desktop/src/app/features/organization/organization-types.ts b/apps/desktop/src/app/features/organization/organization-types.ts new file mode 100644 index 0000000..32436b6 --- /dev/null +++ b/apps/desktop/src/app/features/organization/organization-types.ts @@ -0,0 +1,26 @@ +import type { IpcClient } from "../../../ipc/client"; +import type { OrganizationEntry } from "../../../ipc/domain"; +import type { Project, Session } from "../../../ipc/types"; + +/** Host-owned selection and runtime status remain separate from editable organization. */ +export interface OrganizationPanelProps { + readonly client: Pick; + readonly currentProject?: Pick | null; + readonly currentSession?: Pick | null; + /** Change on reconnect when the host retains its IPC client object. */ + readonly connectionKey?: string | number; + /** Notifies the host of a confirmed save so canvas sorting/filtering can refresh. */ + readonly onChanged?: (entry: OrganizationEntry) => void; +} + +/** Retains user choices and their original revision until an explicit refresh or save. */ +export interface OrganizationDraft { + readonly original: OrganizationEntry; + readonly pinned: boolean; + readonly archived: boolean; + readonly workflow: OrganizationEntry["workflow"]; + readonly error?: string; + readonly notice?: string; + readonly needsRebase?: boolean; + readonly rebased?: boolean; +} diff --git a/apps/desktop/src/app/features/organization/organization.css b/apps/desktop/src/app/features/organization/organization.css new file mode 100644 index 0000000..4ed6eed --- /dev/null +++ b/apps/desktop/src/app/features/organization/organization.css @@ -0,0 +1,28 @@ +.organization-panel { min-width: 0; container-type: inline-size; color: var(--color-text); background: var(--color-surface); } +.organization-panel h2, .organization-panel h3, .organization-panel p { margin: 0; } +.organization-panel h2 { font-size: 1rem; } +.organization-panel h3 { font-size: 0.875rem; overflow-wrap: anywhere; } +.organization-panel__header, .organization-card__header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); } +.organization-panel__header { padding: var(--space-4); border-bottom: 1px solid var(--color-border); } +.organization-panel__header p, .organization-card__header p, .organization-card__hint { color: var(--color-text-muted); font-size: 0.75rem; } +.organization-panel__header p { margin-top: var(--space-1); } +.organization-panel__cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); padding: var(--space-4); } +.organization-panel__cards:has(> :only-child) { grid-template-columns: minmax(0, 1fr); } +.organization-panel .organization-panel__empty, .organization-panel .organization-panel__notice { margin: var(--space-4); color: var(--color-text-muted); } +.organization-card { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface-raised); } +.organization-card__process { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-size: 0.8125rem; } +.organization-card__fields { display: flex; flex-wrap: wrap; gap: var(--space-4); min-width: 0; margin: 0; padding: 0; border: 0; } +.organization-card__fields legend { padding: 0; margin-bottom: var(--space-2); font-size: 0.8125rem; font-weight: 600; } +.organization-card__fields label { display: flex; align-items: center; gap: var(--space-2); min-height: var(--control-height); font-size: 0.8125rem; } +.organization-card__fields input { width: 1rem; height: 1rem; margin: 0; accent-color: var(--color-accent); } +.organization-card__fields .organization-card__workflow { flex-basis: 100%; display: grid; gap: var(--space-1); } +.organization-card__workflow select { min-width: 0; width: 100%; min-height: var(--control-height); border: 1px solid var(--color-border-strong); border-radius: var(--radius-small); padding: var(--space-2); color: var(--color-text); background: var(--color-surface); } +.organization-card__warning, .organization-panel__error { padding: var(--space-3); border-left: 2px solid var(--color-warning); background: var(--color-warning-muted); color: var(--color-warning); font-size: 0.8125rem; overflow-wrap: anywhere; } +.organization-panel__error { border-color: var(--color-danger); color: var(--color-danger); background: var(--color-danger-muted); } +.organization-panel > .organization-panel__error { margin: var(--space-4); } +.organization-card__notice { min-height: 1.25rem; font-size: 0.8125rem; color: var(--color-success); } +.organization-card__saved { font-size: 0.8125rem; } +.organization-card__saved summary { min-height: var(--control-height); cursor: pointer; font-weight: 600; } +.organization-card__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: auto; } +@container (max-width: 42rem) { .organization-panel__cards { grid-template-columns: minmax(0, 1fr); } } +@container (max-width: 22rem) { .organization-card__actions .button { width: 100%; white-space: normal; } } diff --git a/apps/desktop/src/app/features/organization/useOrganization.ts b/apps/desktop/src/app/features/organization/useOrganization.ts new file mode 100644 index 0000000..c549bfd --- /dev/null +++ b/apps/desktop/src/app/features/organization/useOrganization.ts @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from "react"; + +import type { OrganizationEntry, OrganizationTarget } from "../../../ipc/domain"; +import { organizationTargetKey } from "../../../ipc/organization-schema"; +import { IpcContractError } from "../../../ipc/schema"; +import { errorData } from "../../utils"; +import type { OrganizationDraft, OrganizationPanelProps } from "./organization-types"; + +interface LoadState { + readonly client: OrganizationPanelProps["client"]; + readonly connectionKey: OrganizationPanelProps["connectionKey"]; + readonly selectionKey: string; + readonly refreshIndex: number; + readonly error?: string; +} + +interface Operation { + readonly client: OrganizationPanelProps["client"]; + readonly connectionKey: OrganizationPanelProps["connectionKey"]; + readonly token: number; + readonly kind: "saving" | "refreshing"; +} + +/** Preserves each entity's desired flags while correlating asynchronous reads and writes. */ +export function useOrganization({ client, currentProject, currentSession, connectionKey, onChanged }: OrganizationPanelProps) { + const projectId = currentProject?.id; + const sessionId = currentSession?.id; + const selectionKey = `${projectId ?? ""}/${sessionId ?? ""}`; + const [drafts, setDrafts] = useState>({}); + const [load, setLoad] = useState(); + const [refreshIndex, setRefreshIndex] = useState(0); + const [operations, setOperations] = useState>({}); + const pending = useRef(new Map()); + const loadEpoch = useRef(0); + const connectionEpoch = useRef(0); + const writeEpoch = useRef(0); + const operationSequence = useRef(0); + const isCurrent = load?.client === client && load.connectionKey === connectionKey && load.selectionKey === selectionKey && load.refreshIndex === refreshIndex; + const hasTargets = Boolean(projectId || sessionId); + + useEffect(() => { + connectionEpoch.current += 1; + return () => { connectionEpoch.current += 1; }; + }, [client, connectionKey]); + + useEffect(() => { + const epoch = ++loadEpoch.current; + const beforeWrites = writeEpoch.current; + const targets: OrganizationTarget[] = []; + if (projectId) targets.push({ kind: "project", id: projectId }); + if (sessionId) targets.push({ kind: "session", id: sessionId }); + async function read() { + if (!targets.length) return; + try { + const result = await client.getOrganization({ targets }); + if (epoch !== loadEpoch.current || beforeWrites !== writeEpoch.current) return; + if (result.entries.length !== targets.length || result.entries.some((entry, index) => organizationTargetKey(entry.target) !== organizationTargetKey(targets[index]))) throw new IpcContractError("Organization response changed the selected targets"); + setDrafts((previous) => { + const next = { ...previous }; + for (const entry of result.entries) { + const key = organizationTargetKey(entry.target); + const existing = previous[key]; + if (existing && (hasOrganizationChanges(existing) || existing.needsRebase)) continue; + next[key] = { ...fromEntry(entry), notice: existing?.original.revision === entry.revision ? existing.notice : undefined }; + } + return next; + }); + setLoad({ client, connectionKey, selectionKey, refreshIndex }); + } catch (error) { + if (epoch === loadEpoch.current && beforeWrites === writeEpoch.current) setLoad({ client, connectionKey, selectionKey, refreshIndex, error: errorData(error).message }); + } + } + void read(); + return () => { loadEpoch.current += 1; }; + }, [client, connectionKey, projectId, sessionId, selectionKey, refreshIndex]); + + function update(target: OrganizationTarget, patch: Partial>) { + const key = organizationTargetKey(target); + setDrafts((previous) => { + const draft = previous[key]; + return draft ? { ...previous, [key]: { ...draft, ...patch, notice: undefined } } : previous; + }); + } + + function discard(target: OrganizationTarget) { + const key = organizationTargetKey(target); + setDrafts((previous) => { + const draft = previous[key]; + return draft ? { ...previous, [key]: fromEntry(draft.original) } : previous; + }); + } + + async function change(target: OrganizationTarget, kind: Operation["kind"]) { + const key = organizationTargetKey(target); + const draft = drafts[key]; + const active = pending.current.get(key); + if (!draft || (active?.client === client && active.connectionKey === connectionKey)) return; + const operation = { client, connectionKey, kind, token: ++operationSequence.current }; + const transportEpoch = connectionEpoch.current; + pending.current.set(key, operation); + setOperations((previous) => ({ ...previous, [key]: operation })); + setDrafts((previous) => ({ ...previous, [key]: { ...draft, error: undefined, notice: undefined } })); + try { + const updated = kind === "saving" + ? await client.saveOrganization({ target, expectedRevision: draft.original.revision, pinned: draft.pinned, archived: draft.archived, workflow: draft.workflow }) + : (await client.getOrganization({ targets: [target] })).entries[0]; + if (connectionEpoch.current !== transportEpoch || pending.current.get(key)?.token !== operation.token) return; + if (!updated || organizationTargetKey(updated.target) !== key) throw new IpcContractError("Organization operation returned another target"); + writeEpoch.current += 1; + setDrafts((previous) => ({ + ...previous, + [key]: kind === "saving" + ? { ...fromEntry(updated), notice: "Organization saved locally." } + : { ...(previous[key] ?? draft), original: updated, needsRebase: false, rebased: true, error: undefined, notice: "Latest revision loaded. Your choices are preserved; review them before saving." }, + })); + setRefreshIndex((value) => value + 1); + if (kind === "saving") { + try { onChanged?.(updated); } + catch { setDrafts((previous) => ({ ...previous, [key]: { ...fromEntry(updated), error: "Organization was saved, but the workspace could not refresh. Refresh the workspace to see the change." } })); } + } + } catch (error) { + if (connectionEpoch.current !== transportEpoch || pending.current.get(key)?.token !== operation.token) return; + const detail = errorData(error); + setDrafts((previous) => ({ ...previous, [key]: { ...(previous[key] ?? draft), error: detail.message, needsRebase: detail.code === "organization_conflict" || draft.needsRebase } })); + } finally { + if (pending.current.get(key)?.token === operation.token) { + pending.current.delete(key); + setOperations((previous) => ({ ...previous, [key]: undefined })); + } + } + } + + function operationFor(target: OrganizationTarget): Operation["kind"] | undefined { + const operation = operations[organizationTargetKey(target)]; + return operation?.client === client && operation.connectionKey === connectionKey ? operation.kind : undefined; + } + + return { drafts, loading: hasTargets && !isCurrent, error: isCurrent ? load.error : undefined, refresh: () => setRefreshIndex((value) => value + 1), update, discard, change, operationFor }; +} + +function fromEntry(entry: OrganizationEntry): OrganizationDraft { + return { original: entry, pinned: entry.pinned, archived: entry.archived, workflow: entry.workflow }; +} + +/** Computes unsaved choices without duplicating dirty state. */ +export function hasOrganizationChanges(draft: OrganizationDraft): boolean { + return draft.pinned !== draft.original.pinned || draft.archived !== draft.original.archived || draft.workflow !== draft.original.workflow; +} diff --git a/apps/desktop/src/ipc/client.test.ts b/apps/desktop/src/ipc/client.test.ts index 8e7ca32..215a501 100644 --- a/apps/desktop/src/ipc/client.test.ts +++ b/apps/desktop/src/ipc/client.test.ts @@ -12,6 +12,8 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: transport.openPath })); import { createTauriIpcClient } from "./client"; import discoveryFixture from "../../../../protocol/fixtures/knowledge-discovery.json"; +import organizationFixture from "../../../../protocol/fixtures/organization.json"; +import type { OrganizationGetRequest, OrganizationSaveRequest } from "./domain"; import type { RequestEnvelope } from "./types"; const PROJECT = { @@ -466,3 +468,33 @@ describe("discovery IPC transport", () => { expect(capturedRequests()).toHaveLength(1); }); }); + +describe("organization IPC transport", () => { + beforeEach(() => { transport.invoke.mockReset(); }); + + it("batches existing targets and explicitly saves metadata without runtime methods", async () => { + installWireResponder({ "organization.get": organizationFixture.response, "organization.save": organizationFixture.saved }); + const client = createTauriIpcClient(); + const input: OrganizationGetRequest = { targets: [ + { kind: "session", id: organizationFixture.request.targets[0].id }, + { kind: "project", id: organizationFixture.request.targets[1].id }, + ] }; + expect(await client.getOrganization(input)).toEqual(organizationFixture.response); + const save: OrganizationSaveRequest = { target: input.targets[0], expectedRevision: 1, pinned: true, archived: true, workflow: "in_review" }; + expect(await client.saveOrganization(save)).toEqual(organizationFixture.saved); + expect(capturedRequests().map((request) => ({ method: request.method, payload: request.payload }))).toEqual([ + { method: "organization.get", payload: input }, { method: "organization.save", payload: save }, + ]); + }); + + it("rejects an empty batch before transport and preserves optimistic conflicts", async () => { + const client = createTauriIpcClient(); + await expect(client.getOrganization({ targets: [] })).rejects.toThrow(); + expect(transport.invoke).not.toHaveBeenCalled(); + transport.invoke.mockImplementation(async (_command, { request }) => ({ + kind: "response", version: 1, requestId: request.requestId, status: "error", + error: { code: "organization_conflict", message: "Organization changed in another window." }, + })); + await expect(client.saveOrganization({ target: { kind: "project", id: PROJECT.id }, expectedRevision: 0, pinned: true, archived: false, workflow: null })).rejects.toMatchObject({ code: "organization_conflict" }); + }); +}); diff --git a/apps/desktop/src/ipc/client.ts b/apps/desktop/src/ipc/client.ts index 354acc9..1138f77 100644 --- a/apps/desktop/src/ipc/client.ts +++ b/apps/desktop/src/ipc/client.ts @@ -1,6 +1,18 @@ import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; import { decodeKnowledgeDiscoverResponse, decodeKnowledgeReadResponse } from "./discovery-schema"; -import type { KnowledgeEntry, KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeDeleteRequest, KnowledgeDiscoverRequest, KnowledgeDiscoverResponse, KnowledgeReadRequest, KnowledgeReadResponse, WorktreeListRequest } from "./domain"; +import { decodeOrganizationGetResponse, decodeOrganizationSaveResponse, validateOrganizationGet, validateOrganizationSave } from "./organization-schema"; +import type { OrganizationEntry, OrganizationGetRequest, OrganizationGetResponse, OrganizationSaveRequest } from "./domain"; +import type { KnowledgeEntry, KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeDeleteRequest, KnowledgeDiscoverRequest, KnowledgeDiscoverResponse, KnowledgeReadRequest, KnowledgeReadResponse } from "./domain"; +import type { WorktreeListRequest } from "./domain"; +import type { + FileListRequest, + FileListResponse, + FileReadRequest, + FileReadResponse, + FileWriteRequest, + FileWriteResponse, +} from "./domain"; +import { decodeFileListResponse, decodeFileReadResponse, decodeFileWriteResponse } from "./file-schema"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -64,11 +76,16 @@ export interface TerminalResizeInput { /** The sole frontend interface to daemon and native desktop capabilities. */ export interface IpcClient { readonly platform: AppPlatform; + listFiles(input: FileListRequest): Promise; + readFile(input: FileReadRequest): Promise; + writeFile(input: FileWriteRequest): Promise; listKnowledge(input: KnowledgeListRequest): Promise; saveKnowledge(input: KnowledgeSaveRequest): Promise; deleteKnowledge(input: KnowledgeDeleteRequest): Promise; discoverKnowledge(input: KnowledgeDiscoverRequest): Promise; readKnowledge(input: KnowledgeReadRequest): Promise; + getOrganization(input: OrganizationGetRequest): Promise; + saveOrganization(input: OrganizationSaveRequest): Promise; initialize(): Promise; subscribe( handler: IpcEventHandler, @@ -146,6 +163,18 @@ export function toIpcError(error: unknown): IpcError { class TauriIpcClient implements IpcClient { readonly platform = detectPlatform(); + async listFiles(input: FileListRequest): Promise { + return this.fileRequest("file.list", input, (value) => decodeFileListResponse(value, input)); + } + + async readFile(input: FileReadRequest): Promise { + return this.fileRequest("file.read", input, (value) => decodeFileReadResponse(value, input)); + } + + async writeFile(input: FileWriteRequest): Promise { + return this.fileRequest("file.write", input, (value) => decodeFileWriteResponse(value, input)); + } + async listKnowledge(input: KnowledgeListRequest): Promise { return decodeKnowledgePage(await this.request("knowledge.list", input)); } @@ -162,6 +191,16 @@ class TauriIpcClient implements IpcClient { return decodeKnowledgeDiscoverResponse(await this.request("knowledge.discover", input)); } + async getOrganization(input: OrganizationGetRequest): Promise { + validateOrganizationGet(input); + return decodeOrganizationGetResponse(await this.request("organization.get", input), input); + } + + async saveOrganization(input: OrganizationSaveRequest): Promise { + validateOrganizationSave(input); + return decodeOrganizationSaveResponse(await this.request("organization.save", input), input); + } + async readKnowledge(input: KnowledgeReadRequest): Promise { const response = decodeKnowledgeReadResponse(await this.request("knowledge.read", input)); if (response.entry.entryId !== input.entryId) { @@ -335,6 +374,14 @@ class TauriIpcClient implements IpcClient { } } + private async fileRequest(method: string, payload: unknown, decode: (value: unknown) => T): Promise { + try { + return decode(await this.request(method, payload)); + } catch (error) { + throw toIpcError(error); + } + } + private async request(method: string, payload: unknown): Promise { const request = createRequestEnvelope(method, payload); try { diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts index 5f68b6f..c231798 100644 --- a/apps/desktop/src/ipc/domain.ts +++ b/apps/desktop/src/ipc/domain.ts @@ -1,6 +1,6 @@ /** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ export type * from "./types"; -import type { Worktree } from "./types"; +import type { GitTarget, Worktree } from "./types"; /** List managed worktrees; omission includes every registered project. */ export interface WorktreeListRequest { @@ -56,6 +56,68 @@ export interface KnowledgeDeleteRequest { readonly expectedRevision: number; } +/** Registered project directory, session cwd, or managed worktree root. */ +export type FileTarget = GitTarget | { + readonly kind: "worktree"; + readonly worktreeId: string; +}; +/** Canonical padded base64 of relative Unix path bytes; empty means list root. */ +export type FilePath = string; +/** Opaque v1 SHA-256 revision; preserve exactly as returned by the daemon. */ +export type FileRevision = string; + +export interface FileEntry { + readonly pathBase64: FilePath; + readonly displayName: string; + readonly kind: "file" | "directory" | "symlink" | "other"; + readonly sizeBytes?: number; + readonly modifiedAtMs?: number; +} + +export interface FileListRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; + readonly limit?: number; + readonly afterNameBase64?: string; +} + +export interface FileListResponse { + readonly entries: readonly FileEntry[]; + readonly nextAfterNameBase64?: string; + readonly observedAtMs: number; +} + +export interface FileReadRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; +} + +/** Text is bounded to 128 KiB UTF-8 including any BOM; line endings are preserved. */ +export interface FileReadResponse { + readonly pathBase64: FilePath; + readonly text: string; + readonly revision: FileRevision; + readonly sizeBytes: number; + readonly modifiedAtMs?: number; + readonly observedAtMs: number; +} + +/** Existing regular text files only; stale revisions leave the buffer unsaved. */ +export interface FileWriteRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; + readonly text: string; + readonly expectedRevision: FileRevision; +} + +export interface FileWriteResponse { + readonly pathBase64: FilePath; + readonly revision: FileRevision; + readonly sizeBytes: number; + readonly modifiedAtMs?: number; + readonly writtenAtMs: number; +} + /** Mirrors core::knowledge::discovery; discovery never establishes native activation. */ export type KnowledgeSourceKind = "rule" | "skill"; export type KnowledgeProvider = "codex" | "claude" | "cursor"; @@ -107,3 +169,30 @@ export interface KnowledgeReadResponse { readonly entry: KnowledgeSourceEntry; readonly content: string; } + +/** Organization metadata targets existing catalog identities only. */ +export type OrganizationTarget = { readonly kind: "project"; readonly id: string } | { readonly kind: "session"; readonly id: string }; +export type OrganizationWorkflow = "backlog" | "in_progress" | "in_review" | "blocked" | "done"; + +/** Visibility and user-managed workflow, independent of process state. */ +export interface OrganizationEntry { + readonly target: OrganizationTarget; + readonly pinned: boolean; + readonly archived: boolean; + readonly workflow: OrganizationWorkflow | null; + readonly revision: number; + readonly updatedAtMs: number | null; +} + +/** Between one and 100 distinct targets; results retain request order. */ +export interface OrganizationGetRequest { readonly targets: readonly OrganizationTarget[]; } +export interface OrganizationGetResponse { readonly entries: readonly OrganizationEntry[]; } + +/** Explicit replacement of all flags; zero is the revision of unwritten defaults. */ +export interface OrganizationSaveRequest { + readonly target: OrganizationTarget; + readonly expectedRevision: number; + readonly pinned: boolean; + readonly archived: boolean; + readonly workflow: OrganizationWorkflow | null; +} diff --git a/apps/desktop/src/ipc/file-client.test.ts b/apps/desktop/src/ipc/file-client.test.ts new file mode 100644 index 0000000..e90800f --- /dev/null +++ b/apps/desktop/src/ipc/file-client.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const transport = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: transport.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); + +import { createMockIpcClient } from "../test/mockIpc"; +import { createTauriIpcClient, IpcError } from "./client"; +import type { FileListRequest, FileReadRequest, FileWriteRequest } from "./domain"; +import type { RequestEnvelope } from "./types"; + +const REVISION = `v1:${"a".repeat(64)}`; +const NEXT_REVISION = `v1:${"b".repeat(64)}`; +const BYTES = "name-\xff\\file:part.txt"; +const PATH = globalThis.btoa(BYTES); +const TEXT = "\u{feff}olá 🦀\r\nlast line without newline"; +const NOW = 1_787_941_200_000; +const READ: FileReadRequest = { + target: { kind: "session", sessionId: "0198f000-0000-7000-8000-000000000001" }, + pathBase64: PATH, +}; +const WRITE: FileWriteRequest = { + target: { kind: "worktree", worktreeId: "0198f000-0000-7000-8000-000000000002" }, + pathBase64: PATH, + text: TEXT, + expectedRevision: REVISION, +}; +const LIST: FileListRequest = { + target: { kind: "project", projectId: "0198f000-0000-7000-8000-000000000003" }, + pathBase64: "", +}; +const READ_RESPONSE = { + pathBase64: PATH, + text: TEXT, + revision: REVISION, + sizeBytes: new TextEncoder().encode(TEXT).byteLength, + observedAtMs: NOW, +}; +const WRITE_RESPONSE = { + pathBase64: PATH, + revision: NEXT_REVISION, + sizeBytes: READ_RESPONSE.sizeBytes, + writtenAtMs: NOW, +}; +const ENTRY = { + pathBase64: PATH, + displayName: "name-\\xff\\file:part.txt", + kind: "file", +}; + +/** Use the production envelope code and mock only the native transport boundary. */ +function respond(data: unknown): void { + transport.invoke.mockImplementation(async (command: string, args: { request: RequestEnvelope }) => { + expect(command).toBe("daemon_request"); + return { + kind: "response", version: 1, requestId: args.request.requestId, + status: "success", data, + }; + }); +} + +describe("file IPC transport and decoding", () => { + beforeEach(() => { + transport.invoke.mockReset(); + }); + + it("preserves registered targets, byte-exact identifiers, revisions and UTF-8 text across the generic transport", async () => { + const client = createTauriIpcClient(); + const page = { entries: [ENTRY], nextAfterNameBase64: PATH, observedAtMs: NOW }; + respond(page); + const listInput = { ...LIST, limit: 25, afterNameBase64: globalThis.btoa("earlier") }; + expect(await client.listFiles(listInput)).toEqual(page); + + respond({ ...READ_RESPONSE, modifiedAtMs: -1_000 }); + expect(await client.readFile(READ)).toEqual({ ...READ_RESPONSE, modifiedAtMs: -1_000 }); + + respond(WRITE_RESPONSE); + const written = await client.writeFile(WRITE); + expect(written).toEqual(WRITE_RESPONSE); + expect(written).not.toHaveProperty("modifiedAtMs"); + expect(transport.invoke.mock.calls.map(([command, args]) => ({ + command, + method: args.request.method, + payload: args.request.payload, + }))).toEqual([ + { command: "daemon_request", method: "file.list", payload: listInput }, + { command: "daemon_request", method: "file.read", payload: READ }, + { command: "daemon_request", method: "file.write", payload: WRITE }, + ]); + expect(globalThis.atob(written.pathBase64)).toBe(BYTES); + expect(transport.invoke.mock.calls[2][1].request.payload.text).toBe(TEXT); + }); + + it("preserves daemon error messages and conflict/durability metadata without adding file contents", async () => { + const client = createTauriIpcClient(); + for (const code of ["file_conflict", "file_durability_uncertain"]) { + const error = { + code, + message: "Refresh this file before retrying.", + action: "Read the current revision.", + details: { currentRevision: NEXT_REVISION, writeApplied: code === "file_durability_uncertain" }, + }; + transport.invoke.mockImplementation(async (_command: string, args: { request: RequestEnvelope }) => ({ + kind: "response", version: 1, requestId: args.request.requestId, status: "error", error, + })); + try { + await client.writeFile(WRITE); + expect.fail("a failed save must not become an acknowledgement"); + } catch (caught) { + expect(caught).toBeInstanceOf(IpcError); + expect(caught).toMatchObject(error); + expect(JSON.stringify(caught)).not.toContain(TEXT); + } + } + expect(transport.invoke).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["noncanonical base64", { pathBase64: "YQ" }], + ["nonzero padding bits", { pathBase64: "YR==" }], + ["traversal", { pathBase64: globalThis.btoa("../private") }], + ["absolute path", { pathBase64: globalThis.btoa("/private") }], + ["empty path", { pathBase64: "" }], + ["different file", { pathBase64: globalThis.btoa("another.txt") }], + ["oversized path", { pathBase64: globalThis.btoa("a".repeat(4_097)) }], + ["revision", { revision: `v1:${"A".repeat(64)}` }], + ["NUL text", { text: "private-file-body\0", sizeBytes: 18 }], + ["unpaired surrogate", { text: "\ud800", sizeBytes: 3 }], + ["UTF-8 byte bound", { text: "é".repeat(65_537), sizeBytes: 131_074 }], + ["byte count mismatch", { sizeBytes: 1 }], + ["fractional byte count", { sizeBytes: 1.5 }], + ["null modification time", { modifiedAtMs: null }], + ["unsafe timestamp", { observedAtMs: Number.MAX_SAFE_INTEGER + 1 }], + ])("rejects a malformed read response: %s", async (_label, change) => { + respond({ ...READ_RESPONSE, ...change }); + await expect(createTauriIpcClient().readFile(READ)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("contract errors never quote malformed field values or file text", async () => { + const secret = "private-file-body-not-for-diagnostics"; + respond({ ...READ_RESPONSE, text: `${secret}\0`, sizeBytes: secret.length + 1 }); + try { + await createTauriIpcClient().readFile(READ); + expect.fail("NUL text must be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(IpcError); + expect(String(error)).not.toContain(secret); + expect(JSON.stringify(error)).not.toContain(secret); + } + }); + + it("accepts the exact 128 KiB UTF-8 boundary including worst-case JSON escapes", async () => { + const text = "\u0001".repeat(128 * 1_024); + respond({ ...READ_RESPONSE, text, sizeBytes: 128 * 1_024 }); + const response = await createTauriIpcClient().readFile(READ); + expect(response.text).toBe(text); + expect(response.sizeBytes).toBe(128 * 1_024); + }); + + it.each([ + ["another path", { pathBase64: globalThis.btoa("different.txt") }], + ["different byte count", { sizeBytes: WRITE_RESPONSE.sizeBytes + 1 }], + ["unbounded byte count", { sizeBytes: 131_073 }], + ["invalid revision", { revision: "invalid" }], + ["negative publication time", { writtenAtMs: -1 }], + ])("refuses an inconsistent save acknowledgement: %s", async (_label, change) => { + respond({ ...WRITE_RESPONSE, ...change }); + await expect(createTauriIpcClient().writeFile(WRITE)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("ignores future entry kinds conservatively and keeps byte ordering independent of display names", async () => { + const entries = [ + { pathBase64: globalThis.btoa("\x80"), displayName: "z", kind: "future_mount" }, + { pathBase64: globalThis.btoa("\xff"), displayName: "a", kind: "symlink" }, + ]; + respond({ entries, observedAtMs: NOW }); + const result = await createTauriIpcClient().listFiles(LIST); + expect(result.entries).toEqual([{ ...entries[0], kind: "other" }, entries[1]]); + expect(result).not.toHaveProperty("nextAfterNameBase64"); + expect(result.entries[0]).not.toHaveProperty("sizeBytes"); + }); + + it.each([ + ["duplicate identifier", { entries: [ENTRY, ENTRY], observedAtMs: NOW }], + ["nonchild path", { entries: [{ ...ENTRY, pathBase64: globalThis.btoa("nested/file.txt") }], observedAtMs: NOW }], + ["empty page with cursor", { entries: [], nextAfterNameBase64: PATH, observedAtMs: NOW }], + ["cursor on another name", { entries: [ENTRY], nextAfterNameBase64: globalThis.btoa("other"), observedAtMs: NOW }], + ["directory cursor", { entries: [ENTRY], nextAfterNameBase64: globalThis.btoa("nested/file"), observedAtMs: NOW }], + ["null cursor", { entries: [ENTRY], nextAfterNameBase64: null, observedAtMs: NOW }], + ["unsafe entry size", { entries: [{ ...ENTRY, sizeBytes: Number.MAX_SAFE_INTEGER + 1 }], observedAtMs: NOW }], + ["raw display control", { entries: [{ ...ENTRY, displayName: "line\nname" }], observedAtMs: NOW }], + ["unrecognized nonstring kind", { entries: [{ ...ENTRY, kind: null }], observedAtMs: NOW }], + ])("rejects a malformed file listing: %s", async (_label, page) => { + respond(page); + await expect(createTauriIpcClient().listFiles(LIST)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("rejects a page before its exclusive cursor and checks both entry and encoded-byte limits", async () => { + const client = createTauriIpcClient(); + respond({ entries: [ENTRY], observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, afterNameBase64: PATH })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + + const entries = Array.from({ length: 201 }, (_, index) => ({ + pathBase64: globalThis.btoa(index.toString().padStart(3, "0")), displayName: "file", kind: "file", + })); + respond({ entries, observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, limit: 200 })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + + const directory = "d".repeat(4_000); + respond({ entries: entries.slice(0, 100).map((entry) => ({ + ...entry, pathBase64: globalThis.btoa(`${directory}/${globalThis.atob(entry.pathBase64)}`), + })), observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, pathBase64: globalThis.btoa(directory) })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("the injected mock rejects unconfigured editor calls and permits explicit handlers", async () => { + const unhandled = createMockIpcClient(); + await expect(unhandled.listFiles(LIST)).rejects.toThrow("Unexpected IPC call in test: listFiles"); + await expect(unhandled.readFile(READ)).rejects.toThrow("Unexpected IPC call in test: readFile"); + await expect(unhandled.writeFile(WRITE)).rejects.toThrow("Unexpected IPC call in test: writeFile"); + const configured = createMockIpcClient({ handlers: { readFile: async () => READ_RESPONSE } }); + await expect(configured.readFile(READ)).resolves.toEqual(READ_RESPONSE); + expect(configured.readFile).toHaveBeenCalledWith(READ); + }); +}); diff --git a/apps/desktop/src/ipc/file-schema.ts b/apps/desktop/src/ipc/file-schema.ts new file mode 100644 index 0000000..f6a0b1b --- /dev/null +++ b/apps/desktop/src/ipc/file-schema.ts @@ -0,0 +1,197 @@ +import type { + FileEntry, + FileListRequest, + FileListResponse, + FileReadRequest, + FileReadResponse, + FileWriteRequest, + FileWriteResponse, +} from "./domain"; +import { + IpcContractError, + requireArray, + requireRecord, + requireString, +} from "./schema"; + +const MAX_PATH_BYTES = 4_096; +const MAX_TEXT_BYTES = 128 * 1_024; +const MAX_PAGE_BYTES = 512 * 1_024; +const MAX_PAGE_ENTRIES = 200; +const encoder = new TextEncoder(); + +/** Decode exact Unix bytes as a binary string, never as a display filename. */ +function pathBytes(value: unknown, allowRoot = false, singleName = false): string { + const encoded = requireString(value, "file path identifier"); + if (encoded.length > Math.ceil(MAX_PATH_BYTES / 3) * 4) { + throw new IpcContractError("File path identifier exceeds its byte limit"); + } + let bytes: string; + try { + bytes = globalThis.atob(encoded); + } catch { + throw new IpcContractError("File path identifier is not canonical base64"); + } + if (globalThis.btoa(bytes) !== encoded || bytes.length > MAX_PATH_BYTES) { + throw new IpcContractError("File path identifier is not canonical base64"); + } + if (allowRoot && bytes === "") return bytes; + if ( + bytes.includes("\0") || + (singleName && bytes.includes("/")) || + bytes.split("/").some((component) => + component === "" || component === "." || component === ".." + ) + ) { + throw new IpcContractError("File path identifier must name a relative child"); + } + return bytes; +} + +/** Reject values that cannot be represented exactly by the JavaScript client. */ +function integer(value: unknown, label: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new IpcContractError(`${label} must be a bounded safe integer`); + } + return value; +} + +/** File modification times may precede 1970; absent times stay omitted. */ +function modifiedTime(row: Record): { readonly modifiedAtMs?: number } { + return row.modifiedAtMs === undefined ? {} : { + modifiedAtMs: integer(row.modifiedAtMs, "file modification timestamp", Number.MIN_SAFE_INTEGER), + }; +} + +/** Keep Rust UTF-8 strings exact rather than repairing lone UTF-16 surrogates. */ +function isUnicodeScalarText(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const unit = text.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +/** Validate text size and encoding without ever echoing the file contents. */ +function textBytes(value: unknown): { readonly text: string; readonly sizeBytes: number } { + const text = requireString(value, "file text"); + if (text.length > MAX_TEXT_BYTES || text.includes("\0") || !isUnicodeScalarText(text)) { + throw new IpcContractError("File text exceeds its limit or is not valid UTF-8 text"); + } + const sizeBytes = encoder.encode(text).byteLength; + if (sizeBytes > MAX_TEXT_BYTES) { + throw new IpcContractError("File text exceeds its UTF-8 byte limit"); + } + return { text, sizeBytes }; +} + +/** Revisions are opaque; the frontend validates their versioned wire shape. */ +function revision(value: unknown): string { + const revision = requireString(value, "file revision"); + if (!/^v1:[0-9a-f]{64}$/.test(revision)) { + throw new IpcContractError("File revision has an invalid format"); + } + return revision; +} + +/** Decode an observed entry without inferring access rights from its kind. */ +function entry(value: unknown): FileEntry { + const row = requireRecord(value, "file entry"); + const pathBase64 = requireString(row.pathBase64, "file entry path"); + pathBytes(pathBase64); + const displayName = requireString(row.displayName, "file display name"); + if ( + displayName.length === 0 || displayName.length > MAX_PATH_BYTES * 4 || + /\p{Cc}/u.test(displayName) || !isUnicodeScalarText(displayName) || + encoder.encode(displayName).byteLength > MAX_PATH_BYTES * 4 + ) { + throw new IpcContractError("File display name is not bounded escaped text"); + } + const observedKind = requireString(row.kind, "file entry kind"); + const kind = observedKind === "file" || observedKind === "directory" || observedKind === "symlink" + ? observedKind : "other"; + return { + pathBase64, + displayName, + kind, + ...(row.sizeBytes === undefined ? {} : { sizeBytes: integer(row.sizeBytes, "file entry size") }), + ...modifiedTime(row), + }; +} + +/** Validate byte ordering, direct children, continuation and the encoded page limit. */ +export function decodeFileListResponse(value: unknown, input: FileListRequest): FileListResponse { + const row = requireRecord(value, "file list response"); + const rawEntries = requireArray(row.entries, "file entries"); + const limit = integer(input.limit ?? 100, "file page limit", 1, MAX_PAGE_ENTRIES); + if (rawEntries.length > limit) throw new IpcContractError("File page exceeds its entry limit"); + const directory = pathBytes(input.pathBase64, true); + let previous = input.afterNameBase64 === undefined ? undefined : pathBytes(input.afterNameBase64, false, true); + const entries = rawEntries.map((value) => { + const decoded = entry(value); + const bytes = pathBytes(decoded.pathBase64); + const slash = bytes.lastIndexOf("/"); + const parent = slash < 0 ? "" : bytes.slice(0, slash); + const name = bytes.slice(slash + 1); + if (parent !== directory || (previous !== undefined && name <= previous)) { + throw new IpcContractError("File page contains an unrelated, duplicate or unordered entry"); + } + previous = name; + return decoded; + }); + const next = row.nextAfterNameBase64; + if (next !== undefined && (entries.length === 0 || pathBytes(next, false, true) !== previous)) { + throw new IpcContractError("File page continuation does not identify its final entry"); + } + const response: FileListResponse = { + entries, + ...(next === undefined ? {} : { nextAfterNameBase64: requireString(next, "file continuation") }), + observedAtMs: integer(row.observedAtMs, "file observation timestamp"), + }; + if (encoder.encode(JSON.stringify(response)).byteLength > MAX_PAGE_BYTES) { + throw new IpcContractError("File page exceeds its encoded response limit"); + } + return response; +} + +/** Validate the exact content and identity returned for the requested file. */ +export function decodeFileReadResponse(value: unknown, input: FileReadRequest): FileReadResponse { + const row = requireRecord(value, "file read response"); + const pathBase64 = requireString(row.pathBase64, "file read path"); + pathBytes(pathBase64); + if (pathBase64 !== input.pathBase64) throw new IpcContractError("File read returned another identifier"); + const content = textBytes(row.text); + const sizeBytes = integer(row.sizeBytes, "file text size", 0, MAX_TEXT_BYTES); + if (sizeBytes !== content.sizeBytes) throw new IpcContractError("File size does not match its UTF-8 text"); + return { + pathBase64, + text: content.text, + revision: revision(row.revision), + sizeBytes, + ...modifiedTime(row), + observedAtMs: integer(row.observedAtMs, "file observation timestamp"), + }; +} + +/** A save acknowledgement must identify the exact path and submitted byte count. */ +export function decodeFileWriteResponse(value: unknown, input: FileWriteRequest): FileWriteResponse { + const row = requireRecord(value, "file write response"); + const pathBase64 = requireString(row.pathBase64, "file write path"); + pathBytes(pathBase64); + if (pathBase64 !== input.pathBase64) throw new IpcContractError("File save returned another identifier"); + const sizeBytes = integer(row.sizeBytes, "saved file size", 0, MAX_TEXT_BYTES); + if (sizeBytes !== textBytes(input.text).sizeBytes) throw new IpcContractError("File save size differs from the submitted text"); + return { + pathBase64, + revision: revision(row.revision), + sizeBytes, + ...modifiedTime(row), + writtenAtMs: integer(row.writtenAtMs, "file publication timestamp"), + }; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts index cf3672e..57b4570 100644 --- a/apps/desktop/src/ipc/methods.ts +++ b/apps/desktop/src/ipc/methods.ts @@ -1,4 +1,4 @@ -/** Mirror of cli_master_core::wire::method; protocol/catalog.json is checked in tests. */ +/** Mirror of cli_master_core::wire::method; checked against the JSON catalog. */ export const IPC_METHODS = [ "system.hello", "state.snapshot", @@ -28,12 +28,17 @@ export const IPC_METHODS = [ "worktree.list", "worktree.prepare_remove", "worktree.remove", + "file.list", + "file.read", + "file.write", "diagnostics.get", "knowledge.list", "knowledge.save", "knowledge.delete", "knowledge.discover", - "knowledge.read" + "knowledge.read", + "organization.get", + "organization.save" ] as const; export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/apps/desktop/src/ipc/organization-schema.test.ts b/apps/desktop/src/ipc/organization-schema.test.ts new file mode 100644 index 0000000..1a8e94f --- /dev/null +++ b/apps/desktop/src/ipc/organization-schema.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import fixture from "../../../../protocol/fixtures/organization.json"; +import type { OrganizationGetRequest, OrganizationSaveRequest } from "./domain"; +import { decodeOrganizationEntry, decodeOrganizationGetResponse, decodeOrganizationSaveResponse, validateOrganizationGet, validateOrganizationSave } from "./organization-schema"; + +const request: OrganizationGetRequest = { targets: fixture.response.entries.map((entry) => decodeOrganizationEntry(entry).target) }; + +describe("organization Rust/TypeScript contract", () => { + it("decodes the shared defaults in requested order and preserves saved fields", () => { + expect(request).toEqual(fixture.request); + expect(decodeOrganizationGetResponse(fixture.response, request)).toEqual(fixture.response); + expect(decodeOrganizationEntry(fixture.saved)).toEqual(fixture.saved); + }); + + it("requires exact default flags, workflow and explicit null timestamps at revision zero", () => { + for (const patch of [{ pinned: true }, { archived: true }, { workflow: "done" }, { updatedAtMs: 0 }, { updatedAtMs: undefined }, { revision: -1 }]) { + expect(() => decodeOrganizationEntry({ ...fixture.response.entries[0], ...patch })).toThrow(); + } + expect(() => decodeOrganizationEntry({ ...fixture.response.entries[1], workflow: "backlog" })).toThrow(); + expect(() => decodeOrganizationEntry({ ...fixture.response.entries[0], workflow: null })).toThrow(); + expect(() => decodeOrganizationEntry({ ...fixture.response.entries[1], workflow: undefined })).toThrow(); + }); + + it("rejects unsafe saved numbers and unrecognized variants without echoing payload values", () => { + for (const patch of [{ revision: Number.MAX_SAFE_INTEGER + 1 }, { updatedAtMs: null }, { updatedAtMs: -1 }, { updatedAtMs: 0.5 }, { updatedAtMs: Number.MAX_SAFE_INTEGER + 1 }, { pinned: "yes" }, { workflow: "private text" }]) { + expect(() => decodeOrganizationEntry({ ...fixture.saved, ...patch })).toThrow(); + } + expect(() => decodeOrganizationEntry({ ...fixture.saved, target: { kind: "private text", id: fixture.saved.target.id } })).toThrow("Invalid organization target kind"); + }); + + it("rejects empty, oversized and duplicate batches, including UUID case aliases", () => { + const owner = request.targets[0]; + expect(() => validateOrganizationGet({ targets: [] })).toThrow(); + expect(() => validateOrganizationGet({ targets: Array.from({ length: 101 }, () => owner) })).toThrow(); + expect(() => validateOrganizationGet({ targets: [owner, owner] })).toThrow("Duplicate organization targets"); + expect(() => validateOrganizationGet({ targets: [owner, { ...owner, id: owner.id.toUpperCase() }] })).toThrow("Duplicate organization targets"); + }); + + it("rejects missing or reordered response targets instead of attaching metadata to another entity", () => { + expect(() => decodeOrganizationGetResponse({ entries: [] }, request)).toThrow("Organization response does not match requested targets"); + expect(() => decodeOrganizationGetResponse({ entries: [...fixture.response.entries].reverse() }, request)).toThrow("Organization response does not match requested targets"); + }); + + it("correlates a saved response with all desired flags, the target and the next revision", () => { + const saved = decodeOrganizationEntry(fixture.saved); + const input: OrganizationSaveRequest = { target: saved.target, expectedRevision: 1, pinned: saved.pinned, archived: saved.archived, workflow: saved.workflow }; + expect(decodeOrganizationSaveResponse(fixture.saved, input)).toEqual(fixture.saved); + for (const patch of [{ revision: 3 }, { pinned: false }, { archived: false }, { workflow: "done" }]) expect(() => decodeOrganizationSaveResponse({ ...fixture.saved, ...patch }, input)).toThrow(); + expect(() => validateOrganizationSave({ ...input, target: request.targets[1] })).toThrow(); + }); +}); diff --git a/apps/desktop/src/ipc/organization-schema.ts b/apps/desktop/src/ipc/organization-schema.ts new file mode 100644 index 0000000..b993fa0 --- /dev/null +++ b/apps/desktop/src/ipc/organization-schema.ts @@ -0,0 +1,88 @@ +import type { OrganizationEntry, OrganizationGetRequest, OrganizationGetResponse, OrganizationSaveRequest, OrganizationTarget, OrganizationWorkflow } from "./domain"; +import { IpcContractError, requireArray, requireBoolean, requireRecord, requireString } from "./schema"; + +/** Stable identity key shared by batch correlation and draft caches. */ +export function organizationTargetKey(target: OrganizationTarget): string { + return `${target.kind}:${target.id.toLowerCase()}`; +} + +/** Validates catalog IDs without attaching the rejected payload to an error. */ +function target(value: unknown): OrganizationTarget { + const row = requireRecord(value, "organization target"); + if (row.kind !== "project" && row.kind !== "session") throw new IpcContractError("Invalid organization target kind"); + const id = requireString(row.id, "organization target ID"); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) throw new IpcContractError("Invalid organization target ID"); + return { kind: row.kind, id }; +} + +/** Workflow values are user labels rather than native runtime states. */ +export function isOrganizationWorkflow(value: unknown): value is OrganizationWorkflow { + return value === "backlog" || value === "in_progress" || value === "in_review" || value === "blocked" || value === "done"; +} + +function workflow(value: unknown, owner: OrganizationTarget): OrganizationWorkflow | null { + if (owner.kind === "project" && value === null) return null; + if (owner.kind === "session" && isOrganizationWorkflow(value)) return value; + throw new IpcContractError("Organization workflow does not match its target"); +} + +function integer(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new IpcContractError("Invalid organization revision or timestamp"); + return value; +} + +/** Matches Rust's unwritten defaults and persisted-entry invariants. */ +export function decodeOrganizationEntry(value: unknown): OrganizationEntry { + const row = requireRecord(value, "organization entry"); + const owner = target(row.target); + const pinned = requireBoolean(row.pinned, "organization pinned flag"); + const archived = requireBoolean(row.archived, "organization archived flag"); + const progress = workflow(row.workflow, owner); + const revision = integer(row.revision); + const updatedAtMs = row.updatedAtMs === null ? null : integer(row.updatedAtMs); + if (revision === 0 ? pinned || archived || updatedAtMs !== null || (owner.kind === "session" && progress !== "backlog") : updatedAtMs === null) { + throw new IpcContractError("Invalid organization default or saved entry"); + } + return { target: owner, pinned, archived, workflow: progress, revision, updatedAtMs }; +} + +/** Validates the whole batch before sending a read to the daemon. */ +export function validateOrganizationGet(input: OrganizationGetRequest): void { + if (input.targets.length < 1 || input.targets.length > 100) throw new IpcContractError("Organization batches require 1 to 100 targets"); + const targets = input.targets.map(target); + if (new Set(targets.map(organizationTargetKey)).size !== targets.length) throw new IpcContractError("Duplicate organization targets"); +} + +/** Correlates every response with its requested target in the original order. */ +export function decodeOrganizationGetResponse(value: unknown, input: OrganizationGetRequest): OrganizationGetResponse { + validateOrganizationGet(input); + const row = requireRecord(value, "organization response"); + const rawEntries = requireArray(row.entries, "organization entries"); + if (rawEntries.length !== input.targets.length) { + throw new IpcContractError("Organization response does not match requested targets"); + } + const entries = rawEntries.map(decodeOrganizationEntry); + if (entries.some((entry, index) => organizationTargetKey(entry.target) !== organizationTargetKey(input.targets[index]))) { + throw new IpcContractError("Organization response does not match requested targets"); + } + return { entries }; +} + +/** Validates a complete optimistic save before transmitting it. */ +export function validateOrganizationSave(input: OrganizationSaveRequest): void { + const owner = target(input.target); + integer(input.expectedRevision); + requireBoolean(input.pinned, "organization pinned flag"); + requireBoolean(input.archived, "organization archived flag"); + workflow(input.workflow, owner); +} + +/** Prevents another target or different desired flags from being reported as saved. */ +export function decodeOrganizationSaveResponse(value: unknown, input: OrganizationSaveRequest): OrganizationEntry { + validateOrganizationSave(input); + const entry = decodeOrganizationEntry(value); + if (organizationTargetKey(entry.target) !== organizationTargetKey(input.target) || entry.revision !== input.expectedRevision + 1 || entry.pinned !== input.pinned || entry.archived !== input.archived || entry.workflow !== input.workflow) { + throw new IpcContractError("Organization save response does not match the requested change"); + } + return entry; +} diff --git a/apps/desktop/src/test/mockIpc.ts b/apps/desktop/src/test/mockIpc.ts index 4523b4e..3c2dddd 100644 --- a/apps/desktop/src/test/mockIpc.ts +++ b/apps/desktop/src/test/mockIpc.ts @@ -24,11 +24,16 @@ export interface MockIpcClientOptions { /** An injected IPC fake whose unconfigured application calls fail loudly. */ export interface MockIpcClient extends IpcClient { + readonly listFiles: Mock; + readonly readFile: Mock; + readonly writeFile: Mock; readonly listKnowledge: Mock; readonly saveKnowledge: Mock; readonly deleteKnowledge: Mock; readonly discoverKnowledge: Mock; readonly readKnowledge: Mock; + readonly getOrganization: Mock; + readonly saveOrganization: Mock; readonly initialize: Mock; readonly subscribe: Mock; readonly subscribeTerminal: Mock; @@ -93,6 +98,9 @@ export function createMockIpcClient( return { platform: options.platform ?? "linux", + listFiles: vi.fn(handlers.listFiles ?? (() => rejectUnhandled("listFiles"))), + readFile: vi.fn(handlers.readFile ?? (() => rejectUnhandled("readFile"))), + writeFile: vi.fn(handlers.writeFile ?? (() => rejectUnhandled("writeFile"))), initialize, subscribe, subscribeTerminal: vi.fn( @@ -158,6 +166,8 @@ export function createMockIpcClient( deleteKnowledge: vi.fn(handlers.deleteKnowledge ?? (() => rejectUnhandled("deleteKnowledge"))), discoverKnowledge: vi.fn(handlers.discoverKnowledge ?? (() => rejectUnhandled("discoverKnowledge"))), readKnowledge: vi.fn(handlers.readKnowledge ?? (() => rejectUnhandled("readKnowledge"))), + getOrganization: vi.fn(handlers.getOrganization ?? (() => rejectUnhandled("getOrganization"))), + saveOrganization: vi.fn(handlers.saveOrganization ?? (() => rejectUnhandled("saveOrganization"))), openPath: vi.fn( handlers.openPath ?? (() => rejectUnhandled("openPath")), ), diff --git a/crates/agents/src/process.rs b/crates/agents/src/process.rs index 7538371..ba61e42 100644 --- a/crates/agents/src/process.rs +++ b/crates/agents/src/process.rs @@ -334,6 +334,9 @@ mod tests { assert!(is_transient_spawn_error(&io::Error::from( io::ErrorKind::Interrupted ))); + assert!(is_transient_spawn_error(&io::Error::from_raw_os_error( + nix::errno::Errno::ETXTBSY as i32 + ))); assert!(!is_transient_spawn_error(&io::Error::from( io::ErrorKind::PermissionDenied ))); diff --git a/crates/agents/tests/probe.rs b/crates/agents/tests/probe.rs index a85ef62..5602b95 100644 --- a/crates/agents/tests/probe.rs +++ b/crates/agents/tests/probe.rs @@ -24,6 +24,106 @@ fn version_probe_captures_first_line_with_timeout() { assert_eq!(report.launch_test, LaunchTestStatus::Success); } +#[test] +fn failed_probe_diagnostic_contains_only_error_kind_and_os_code() { + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "probe-path-secret", "echo unused"); + std::fs::write( + &path, + b"#!/missing-probe-interpreter-secret/TOKEN=must-not-appear\n", + ) + .expect("invalid interpreter fixture should be written"); + + let report = test_executable(&path, &isolated_env(&temp), ProbeOptions::default()); + let LaunchTestStatus::Failed { message } = report.launch_test else { + panic!("missing interpreter must fail the probe"); + }; + assert!(message.contains("kind: NotFound")); + assert!(message.contains(&format!("os error: {}", nix::errno::Errno::ENOENT as i32))); + for sensitive in [ + "probe-path-secret", + "interpreter-secret", + "TOKEN", + "must-not-appear", + ] { + assert!(!message.contains(sensitive)); + } + assert!(!message.contains("spawn")); +} + +#[cfg(target_os = "linux")] +#[test] +fn version_probe_retries_a_busy_executable_until_its_writer_closes() { + use std::{ + fs::OpenOptions, + process::Command, + sync::mpsc::{self, RecvTimeoutError}, + thread, + }; + + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "busy-probe", "echo 'fixture-cli 1.0'"); + let writer = OpenOptions::new() + .write(true) + .open(&path) + .expect("fixture writer should remain open"); + let error = Command::new(&path) + .arg("--version") + .spawn() + .expect_err("the held writer must cause real ETXTBSY"); + assert_eq!( + error.raw_os_error(), + Some(nix::errno::Errno::ETXTBSY as i32) + ); + + let environment = isolated_env(&temp); + let (sender, receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + let report = test_executable(&path, &environment, ProbeOptions::default()); + sender + .send(report) + .expect("test receiver should remain open"); + }); + // Keep ETXTBSY in force during multiple bounded retries. An immediate + // Failed report would demonstrate that the busy executable was not retried. + let early_result = receiver.recv_timeout(Duration::from_millis(60)); + drop(writer); + worker.join().expect("probe worker should finish"); + assert!( + matches!(early_result, Err(RecvTimeoutError::Timeout)), + "probe must remain pending while the writer is held: {early_result:?}" + ); + let report = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("probe should complete after the writer closes"); + assert_eq!(report.launch_test, LaunchTestStatus::Success); + assert_eq!(report.version.as_deref(), Some("fixture-cli 1.0")); +} + +#[cfg(target_os = "linux")] +#[test] +fn busy_executable_retries_remain_bounded_by_the_probe_deadline() { + use std::{fs::OpenOptions, time::Instant}; + + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "busy-probe", "echo 'fixture-cli 1.0'"); + let writer = OpenOptions::new() + .write(true) + .open(&path) + .expect("fixture writer should remain open"); + let started = Instant::now(); + let report = test_executable( + &path, + &isolated_env(&temp), + ProbeOptions::default().with_timeout(Duration::from_millis(60)), + ); + drop(writer); + + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(report.launch_test, LaunchTestStatus::Timeout); + assert!(report.version.is_none()); +} + #[test] fn version_probe_times_out_on_hanging_executable() { let temp = TempDir::new().expect("temporary directory should be created"); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bea1173..cff9c6b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +base64 = "0.22.1" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" uuid = { version = "1.26.0", features = ["serde", "v7"] } diff --git a/crates/core/src/knowledge/discovery.rs b/crates/core/src/knowledge/discovery.rs index fda7d7d..5d630f0 100644 --- a/crates/core/src/knowledge/discovery.rs +++ b/crates/core/src/knowledge/discovery.rs @@ -126,7 +126,7 @@ pub struct KnowledgeSourceEntry { pub source_path: String, /// Filesystem name, not parsed or executed frontmatter. pub name: String, - /// Relative project scope ("." here), or empty for global/admin sources. + /// Relative project scope ("." or a nested path), or empty for global/admin sources. pub scope_directory: String, /// Static documented precedence caveat; never an effective/active assertion. pub precedence_hint: String, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2cb3f64..fcb952b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -11,6 +11,7 @@ mod error; mod ids; pub mod knowledge; mod model; +pub mod organization; mod protocol; mod redact; pub mod wire; diff --git a/crates/core/src/organization/mod.rs b/crates/core/src/organization/mod.rs new file mode 100644 index 0000000..ccbb683 --- /dev/null +++ b/crates/core/src/organization/mod.rs @@ -0,0 +1,398 @@ +//! Pure organization metadata, deliberately separate from process lifecycle. + +use std::{collections::BTreeSet, error::Error, fmt}; + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ProjectId, SessionId}; + +/// Maximum number of distinct entities in one organization read. +pub const MAX_ORGANIZATION_TARGETS: usize = 100; +/// Highest organization revision representable exactly by JavaScript. +pub const MAX_ORGANIZATION_REVISION: u64 = 9_007_199_254_740_991; + +/// An existing project or session selected through the established catalog. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum OrganizationTarget { + /// Organization metadata for a registered project. + Project { + /// Existing project identifier. + id: ProjectId, + }, + /// Organization metadata for a daemon-owned session. + Session { + /// Existing session identifier. + id: SessionId, + }, +} + +/// User-managed work progress; never a signal about process liveness. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OrganizationWorkflow { + /// Work has not begun. + Backlog, + /// Work is underway. + InProgress, + /// Work awaits review. + InReview, + /// Work is blocked on a dependency. + Blocked, + /// Work is considered complete by the user. + Done, +} + +/// Safe validation failure containing a static explanation only. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OrganizationValidationError(&'static str); + +impl fmt::Display for OrganizationValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl Error for OrganizationValidationError {} + +/// Validated, ordered batch containing between one and 100 distinct targets. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct OrganizationTargets(Vec); + +impl OrganizationTargets { + /// Validates batch size and uniqueness while retaining caller order. + /// + /// # Errors + /// + /// Rejects empty, oversized, or duplicate-target batches. + pub fn try_new(targets: Vec) -> Result { + if targets.is_empty() || targets.len() > MAX_ORGANIZATION_TARGETS { + return Err(OrganizationValidationError( + "targets must contain between 1 and 100 entries", + )); + } + if targets.iter().copied().collect::>().len() != targets.len() { + return Err(OrganizationValidationError( + "targets must not contain duplicates", + )); + } + Ok(Self(targets)) + } + + /// Returns the validated targets in request order. + #[must_use] + pub fn as_slice(&self) -> &[OrganizationTarget] { + &self.0 + } + + /// Consumes the batch and returns its ordered targets. + #[must_use] + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl<'de> Deserialize<'de> for OrganizationTargets { + fn deserialize>(deserializer: D) -> Result { + let targets = Vec::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid organization targets"))?; + Self::try_new(targets).map_err(de::Error::custom) + } +} + +/// Reads organization metadata without creating default rows. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OrganizationGetRequest { + /// Existing entities in the desired response order. + pub targets: OrganizationTargets, +} + +/// Organization metadata in exactly the requested target order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OrganizationGetResponse { + /// One entry per requested existing entity. + pub entries: Vec, +} + +/// Visibility and workflow metadata independent of session process state. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrganizationEntry { + /// Existing entity that owns the organization metadata. + pub target: OrganizationTarget, + /// User selected the entity for pinned display. + pub pinned: bool, + /// User archived the entity; a session may continue executing. + pub archived: bool, + /// Null for projects; user-managed work progress for sessions. + pub workflow: Option, + /// Zero for unwritten defaults; positive after an explicit save. + pub revision: u64, + /// Null for unwritten defaults; otherwise Unix epoch milliseconds. + pub updated_at_ms: Option, +} + +impl OrganizationEntry { + /// Returns unwritten defaults for an existing entity. + #[must_use] + pub const fn defaults(target: OrganizationTarget) -> Self { + Self { + target, + pinned: false, + archived: false, + workflow: match target { + OrganizationTarget::Project { .. } => None, + OrganizationTarget::Session { .. } => Some(OrganizationWorkflow::Backlog), + }, + revision: 0, + updated_at_ms: None, + } + } + + /// Checks workflow, revision, timestamp, and unwritten-default consistency. + /// + /// # Errors + /// + /// Rejects mismatched target/workflow, unsafe numbers, or non-default revision-zero values. + pub fn validate(&self) -> Result<(), OrganizationValidationError> { + validate_workflow(self.target, self.workflow)?; + validate_revision(self.revision)?; + if self.revision == 0 { + if self != &Self::defaults(self.target) { + return Err(OrganizationValidationError( + "revision zero requires unwritten default values", + )); + } + } else if !self + .updated_at_ms + .is_some_and(|value| (0..=9_007_199_254_740_991).contains(&value)) + { + return Err(OrganizationValidationError( + "saved entries require a non-negative JavaScript-safe updatedAtMs", + )); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for OrganizationEntry { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + target: OrganizationTarget, + pinned: bool, + archived: bool, + #[serde(deserialize_with = "required_nullable")] + workflow: Option, + revision: u64, + #[serde(deserialize_with = "required_nullable")] + updated_at_ms: Option, + } + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid organization entry"))?; + let entry = Self { + target: fields.target, + pinned: fields.pinned, + archived: fields.archived, + workflow: fields.workflow, + revision: fields.revision, + updated_at_ms: fields.updated_at_ms, + }; + entry.validate().map_err(de::Error::custom)?; + Ok(entry) + } +} + +/// Replaces all organization flags with an optimistic revision check. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrganizationSaveRequest { + /// Existing project or session whose organization metadata changes. + pub target: OrganizationTarget, + /// Zero for the first explicit save; otherwise the observed revision. + pub expected_revision: u64, + /// Complete desired pinned flag. + pub pinned: bool, + /// Complete desired archived flag. + pub archived: bool, + /// Explicit null for projects; required workflow for sessions. + pub workflow: Option, +} + +impl OrganizationSaveRequest { + /// Validates target/workflow consistency and the expected revision range. + /// + /// # Errors + /// + /// Rejects project workflow, missing session workflow, or an unsafe revision. + pub fn validate(&self) -> Result<(), OrganizationValidationError> { + validate_workflow(self.target, self.workflow)?; + validate_revision(self.expected_revision) + } +} + +impl<'de> Deserialize<'de> for OrganizationSaveRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + target: OrganizationTarget, + expected_revision: u64, + pinned: bool, + archived: bool, + #[serde(deserialize_with = "required_nullable")] + workflow: Option, + } + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid organization save request"))?; + let request = Self { + target: fields.target, + expected_revision: fields.expected_revision, + pinned: fields.pinned, + archived: fields.archived, + workflow: fields.workflow, + }; + request.validate().map_err(de::Error::custom)?; + Ok(request) + } +} + +fn required_nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +fn validate_workflow( + target: OrganizationTarget, + workflow: Option, +) -> Result<(), OrganizationValidationError> { + match (target, workflow) { + (OrganizationTarget::Project { .. }, None) + | (OrganizationTarget::Session { .. }, Some(_)) => Ok(()), + _ => Err(OrganizationValidationError( + "projects require null workflow and sessions require a workflow value", + )), + } +} + +const fn validate_revision(revision: u64) -> Result<(), OrganizationValidationError> { + if revision > MAX_ORGANIZATION_REVISION { + Err(OrganizationValidationError( + "revision must be a JavaScript-safe non-negative integer", + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn batches_are_bounded_distinct_typed_and_ordered() { + let target = OrganizationTarget::Project { + id: ProjectId::new(), + }; + assert!(OrganizationTargets::try_new(vec![]).is_err()); + assert!(OrganizationTargets::try_new(vec![target, target]).is_err()); + let targets = (0..100) + .map(|_| OrganizationTarget::Session { + id: SessionId::new(), + }) + .collect::>(); + assert_eq!( + OrganizationTargets::try_new(targets.clone()) + .unwrap() + .as_slice(), + targets + ); + assert!(OrganizationTargets::try_new([targets, vec![target]].concat()).is_err()); + assert!( + serde_json::from_value::(json!({"targets":[target,target]})) + .is_err() + ); + assert!( + serde_json::from_value::( + json!({"kind":"project","id":ProjectId::new(),"pid":1}) + ) + .is_err() + ); + } + + #[test] + fn defaults_require_explicit_null_timestamp_and_matching_workflow() { + for target in [ + OrganizationTarget::Project { + id: ProjectId::new(), + }, + OrganizationTarget::Session { + id: SessionId::new(), + }, + ] { + let defaults = OrganizationEntry::defaults(target); + let value = serde_json::to_value(&defaults).unwrap(); + assert_eq!(value["revision"], 0); + assert!( + value + .get("updatedAtMs") + .is_some_and(serde_json::Value::is_null) + ); + assert_eq!( + serde_json::from_value::(value.clone()).unwrap(), + defaults + ); + let mut invalid = value; + invalid["pinned"] = json!(true); + assert!(serde_json::from_value::(invalid).is_err()); + } + } + + #[test] + fn saves_require_whole_record_and_correct_target_workflow() { + let mut project = json!({"target":{"kind":"project","id":ProjectId::new()},"expectedRevision":0,"pinned":true,"archived":false,"workflow":null}); + assert!(serde_json::from_value::(project.clone()).is_ok()); + project.as_object_mut().unwrap().remove("workflow"); + assert!(serde_json::from_value::(project.clone()).is_err()); + project["workflow"] = json!("done"); + assert!(serde_json::from_value::(project.clone()).is_err()); + project["target"] = json!({"kind":"session","id":SessionId::new()}); + for workflow in ["backlog", "in_progress", "in_review", "blocked", "done"] { + project["workflow"] = json!(workflow); + assert!(serde_json::from_value::(project.clone()).is_ok()); + } + project["workflow"] = serde_json::Value::Null; + assert!(serde_json::from_value::(project).is_err()); + } + + #[test] + fn revisions_and_timestamps_reject_unsafe_or_inconsistent_values() { + let target = OrganizationTarget::Project { + id: ProjectId::new(), + }; + let mut entry = OrganizationEntry { + revision: 1, + updated_at_ms: Some(0), + ..OrganizationEntry::defaults(target) + }; + assert!(entry.validate().is_ok()); + for timestamp in [None, Some(-1), Some(9_007_199_254_740_992)] { + entry.updated_at_ms = timestamp; + assert!(serde_json::from_value::(json!(entry)).is_err()); + } + for revision in [ + json!(-1), + json!(1.5), + json!(MAX_ORGANIZATION_REVISION + 1), + json!("1"), + ] { + assert!(serde_json::from_value::(json!({"target":target,"expectedRevision":revision,"pinned":false,"archived":false,"workflow":null})).is_err()); + } + } +} diff --git a/crates/core/src/wire/files.rs b/crates/core/src/wire/files.rs new file mode 100644 index 0000000..2d04977 --- /dev/null +++ b/crates/core/src/wire/files.rs @@ -0,0 +1,514 @@ +//! Pure local file identifiers and editor request/response contracts. + +use std::fmt; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +use super::{GitTarget, WireValidationError}; + +/// Maximum decoded length of a relative Unix file identifier. +pub const MAX_FILE_PATH_BYTES: usize = 4_096; +/// Maximum UTF-8 byte length accepted by the first text-editor slice. +pub const MAX_FILE_TEXT_BYTES: usize = 128 * 1_024; +/// Default number of entries requested in one directory page. +pub const DEFAULT_FILE_LIST_LIMIT: u16 = 100; +/// Maximum number of entries requested in one directory page. +pub const MAX_FILE_LIST_LIMIT: u16 = 200; + +/// A registered project, session or worktree whose root the daemon resolves. +pub type FileTarget = GitTarget; + +/// Byte-exact relative Unix path represented as canonical padded base64. +/// +/// Empty bytes identify the target root for directory listing only. This type +/// does not interpret Unix filenames as Git pathspecs or Windows paths. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FilePath { + encoded: String, + #[serde(skip)] + bytes: Vec, +} + +impl FilePath { + /// Validates an encoded relative path without accessing the filesystem. + /// + /// # Errors + /// + /// Rejects noncanonical base64, oversized paths, NUL, absolute paths, empty + /// components and the traversal components `.` or `..`. + pub fn try_new(encoded: impl Into) -> Result { + let encoded = encoded.into(); + if encoded.len() > MAX_FILE_PATH_BYTES.div_ceil(3) * 4 { + return Err(path_error("must decode to at most 4096 bytes")); + } + let bytes = STANDARD + .decode(&encoded) + .map_err(|_| path_error("must use canonical padded base64"))?; + if STANDARD.encode(&bytes) != encoded { + return Err(path_error("must use canonical padded base64")); + } + validate_path_bytes(&bytes)?; + Ok(Self { encoded, bytes }) + } + + /// Validates exact Unix path bytes and encodes their wire identifier. + /// + /// # Errors + /// + /// Returns an error for a path violating the relative path invariants. + pub fn try_from_bytes(bytes: impl AsRef<[u8]>) -> Result { + let bytes = bytes.as_ref(); + validate_path_bytes(bytes)?; + Ok(Self { + encoded: STANDARD.encode(bytes), + bytes: bytes.to_vec(), + }) + } + + /// Returns the root identifier accepted by directory listing. + #[must_use] + pub fn root() -> Self { + Self { + encoded: String::new(), + bytes: Vec::new(), + } + } + + /// Returns the exact decoded bytes; display text must not replace them. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the canonical base64 wire value. + #[must_use] + pub fn as_str(&self) -> &str { + &self.encoded + } + + /// Returns whether this path refers to the registered root. + #[must_use] + pub fn is_root(&self) -> bool { + self.bytes.is_empty() + } +} + +impl<'de> Deserialize<'de> for FilePath { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// A nonempty single Unix filename used as a directory pagination cursor. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FileName(FilePath); + +impl FileName { + /// Validates a canonical base64 filename without directory components. + /// + /// # Errors + /// + /// Rejects invalid paths, the root, and names containing a slash. + pub fn try_new(encoded: impl Into) -> Result { + Self::from_path(FilePath::try_new(encoded)?) + } + + /// Encodes and validates an exact Unix filename. + /// + /// # Errors + /// + /// Rejects invalid paths, the root, and names containing a slash. + pub fn try_from_bytes(bytes: impl AsRef<[u8]>) -> Result { + Self::from_path(FilePath::try_from_bytes(bytes)?) + } + + fn from_path(path: FilePath) -> Result { + if path.is_root() || path.as_bytes().contains(&b'/') { + return Err(WireValidationError::new( + "afterNameBase64", + "must identify one nonempty filename", + )); + } + Ok(Self(path)) + } + + /// Returns exact filename bytes for lexicographic cursor comparisons. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + /// Returns the canonical base64 cursor. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for FileName { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Opaque versioned content/identity digest produced by the daemon. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FileRevision(String); + +impl FileRevision { + /// Validates the wire shape without inspecting or hashing a file. + /// + /// # Errors + /// + /// Requires the exact prefix `v1:` followed by 64 lowercase hex digits. + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + if value.len() != 67 + || !value.starts_with("v1:") + || !value.as_bytes()[3..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(WireValidationError::new( + "revision", + "must use v1: followed by 64 lowercase hexadecimal digits", + )); + } + Ok(Self(value)) + } + + /// Returns the opaque revision string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for FileRevision { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Request to list one registered target directory with a bounded page size. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileListRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Relative directory; an empty identifier means the target root. + pub path_base64: FilePath, + /// Requested page size, between one and 200 inclusive. + pub limit: u16, + /// Exclusive cursor compared using exact Unix filename bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub after_name_base64: Option, +} + +impl FileListRequest { + /// Builds a bounded directory request, using 100 when the limit is absent. + /// + /// # Errors + /// + /// Rejects a zero page size or a size above 200 entries. + pub fn try_new( + target: FileTarget, + path_base64: FilePath, + limit: Option, + after_name_base64: Option, + ) -> Result { + let limit = limit.unwrap_or(DEFAULT_FILE_LIST_LIMIT); + if !(1..=MAX_FILE_LIST_LIMIT).contains(&limit) { + return Err(WireValidationError::new( + "limit", + "must be between 1 and 200", + )); + } + Ok(Self { + target, + path_base64, + limit, + after_name_base64, + }) + } +} + +impl<'de> Deserialize<'de> for FileListRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + #[serde(default)] + limit: Option, + #[serde(default)] + after_name_base64: Option, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new( + value.target, + value.path_base64, + value.limit, + value.after_name_base64, + ) + .map_err(de::Error::custom) + } +} + +/// Request to read a bounded existing text file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileReadRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Nonempty relative file identifier. + pub path_base64: FilePath, +} + +impl FileReadRequest { + /// Builds a read request for a non-root identifier. + /// + /// # Errors + /// + /// Rejects the empty root identifier. + pub fn try_new(target: FileTarget, path_base64: FilePath) -> Result { + validate_leaf_path(&path_base64)?; + Ok(Self { + target, + path_base64, + }) + } +} + +impl<'de> Deserialize<'de> for FileReadRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new(value.target, value.path_base64).map_err(de::Error::custom) + } +} + +/// Request to replace an existing text file after an optimistic revision check. +#[derive(Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileWriteRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Nonempty relative file identifier; this operation never creates a file. + pub path_base64: FilePath, + /// Exact replacement UTF-8 text, without implicit newline or BOM changes. + pub text: String, + /// Revision that the caller read before editing. + pub expected_revision: FileRevision, +} + +impl FileWriteRequest { + /// Builds a bounded request without accessing the file or interpreting text. + /// + /// # Errors + /// + /// Rejects the root, NUL-containing text or more than 128 KiB of UTF-8 bytes. + pub fn try_new( + target: FileTarget, + path_base64: FilePath, + text: impl Into, + expected_revision: FileRevision, + ) -> Result { + validate_leaf_path(&path_base64)?; + let text = text.into(); + validate_text(&text)?; + Ok(Self { + target, + path_base64, + text, + expected_revision, + }) + } +} + +impl fmt::Debug for FileWriteRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FileWriteRequest") + .field("target", &self.target) + .field("path_base64", &self.path_base64) + .field("text_bytes", &self.text.len()) + .field("expected_revision", &self.expected_revision) + .finish() + } +} + +impl<'de> Deserialize<'de> for FileWriteRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + text: String, + expected_revision: FileRevision, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new( + value.target, + value.path_base64, + value.text, + value.expected_revision, + ) + .map_err(de::Error::custom) + } +} + +/// Observed entry type; visibility does not grant permission to open an entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileEntryKind { + /// A regular file, which can still be oversized, binary or inaccessible. + File, + /// A directory that may be listed through descriptor-relative traversal. + Directory, + /// A visible symlink that the file service does not follow. + Symlink, + /// A device, socket, FIFO or future unsupported entry kind. + #[serde(other)] + Other, +} + +/// One observed directory entry with separate identity and display text. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileEntry { + /// Exact relative identifier, never reconstructed from the display name. + pub path_base64: FilePath, + /// Escaped display text suitable for rendering as text, not HTML. + pub display_name: String, + /// Observed filesystem kind. + pub kind: FileEntryKind, + /// Observed file length when meaningful for the entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, +} + +/// A bounded observed directory page, not a filesystem transaction snapshot. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileListResponse { + /// Directory entries in exact filename-byte order. + pub entries: Vec, + /// Cursor for the next page; absence means no more entries were observed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_after_name_base64: Option, + /// Observation time in Unix epoch milliseconds. + pub observed_at_ms: i64, +} + +/// Exact text and revision observed through a descriptor-safe bounded read. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileReadResponse { + /// Exact relative file identifier. + pub path_base64: FilePath, + /// File contents, preserving line endings and any BOM. + pub text: String, + /// Opaque revision derived from content and filesystem identity. + pub revision: FileRevision, + /// Observed UTF-8 byte length. + pub size_bytes: u64, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, + /// Observation time in Unix epoch milliseconds. + pub observed_at_ms: i64, +} + +impl fmt::Debug for FileReadResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FileReadResponse") + .field("path_base64", &self.path_base64) + .field("text_bytes", &self.text.len()) + .field("revision", &self.revision) + .field("size_bytes", &self.size_bytes) + .field("modified_at_ms", &self.modified_at_ms) + .field("observed_at_ms", &self.observed_at_ms) + .finish() + } +} + +/// Identity and revision of an atomically published text save. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileWriteResponse { + /// Exact relative file identifier. + pub path_base64: FilePath, + /// Revision of the published file. + pub revision: FileRevision, + /// Published UTF-8 byte length. + pub size_bytes: u64, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, + /// Publication time in Unix epoch milliseconds. + pub written_at_ms: i64, +} + +fn path_error(message: &'static str) -> WireValidationError { + WireValidationError::new("pathBase64", message) +} + +fn validate_path_bytes(bytes: &[u8]) -> Result<(), WireValidationError> { + if bytes.len() > MAX_FILE_PATH_BYTES { + return Err(path_error("must decode to at most 4096 bytes")); + } + if bytes.contains(&0) { + return Err(path_error("must not contain a NUL byte")); + } + if !bytes.is_empty() + && bytes + .split(|byte| *byte == b'/') + .any(|component| component.is_empty() || component == b"." || component == b"..") + { + return Err(path_error( + "must use nonempty relative child components without traversal", + )); + } + Ok(()) +} + +fn validate_leaf_path(path: &FilePath) -> Result<(), WireValidationError> { + if path.is_root() { + return Err(path_error( + "must identify a file rather than the target root", + )); + } + Ok(()) +} + +fn validate_text(text: &str) -> Result<(), WireValidationError> { + if text.len() > MAX_FILE_TEXT_BYTES { + return Err(WireValidationError::new( + "text", + "must be at most 131072 UTF-8 bytes", + )); + } + if text.contains('\0') { + return Err(WireValidationError::new( + "text", + "must not contain a NUL byte", + )); + } + Ok(()) +} diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index d585b1b..0ad663d 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -62,6 +62,13 @@ pub const WORKTREE_PREPARE_REMOVE: &str = "worktree.prepare_remove"; /// Remove a managed worktree after token-bound state confirmation. pub const WORKTREE_REMOVE: &str = "worktree.remove"; +/// List a bounded directory under a registered file target. +pub const FILE_LIST: &str = "file.list"; +/// Read bounded UTF-8 text with an opaque revision. +pub const FILE_READ: &str = "file.read"; +/// Atomically save an existing text file after checking its revision. +pub const FILE_WRITE: &str = "file.write"; + /// Read a sanitized local diagnostic snapshot. pub const DIAGNOSTICS_GET: &str = "diagnostics.get"; @@ -76,6 +83,11 @@ pub const KNOWLEDGE_DISCOVER: &str = "knowledge.discover"; /// Read a bounded source selected by an expiring discovery capability. pub const KNOWLEDGE_READ: &str = "knowledge.read"; +/// Read a bounded batch of project/session organization metadata. +pub const ORGANIZATION_GET: &str = "organization.get"; +/// Save visibility/workflow metadata after comparing its revision. +pub const ORGANIZATION_SAVE: &str = "organization.save"; + /// Every method implemented by the Beta v1 contract. pub const ALL: &[&str] = &[ SYSTEM_HELLO, @@ -106,12 +118,17 @@ pub const ALL: &[&str] = &[ WORKTREE_LIST, WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, + FILE_LIST, + FILE_READ, + FILE_WRITE, DIAGNOSTICS_GET, KNOWLEDGE_LIST, KNOWLEDGE_SAVE, KNOWLEDGE_DELETE, KNOWLEDGE_DISCOVER, KNOWLEDGE_READ, + ORGANIZATION_GET, + ORGANIZATION_SAVE, ]; /// Returns whether a dotted method belongs to the Beta v1 contract. diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index 0c0e970..0a1599d 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -8,6 +8,7 @@ pub mod event_name; pub mod method; mod event; +mod files; mod git_path; mod request; mod response; @@ -25,12 +26,21 @@ pub use crate::knowledge::{ KnowledgeValidationError, MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, MAX_KNOWLEDGE_TITLE_BYTES, }; +pub use crate::organization::{ + OrganizationEntry, OrganizationGetRequest, OrganizationGetResponse, OrganizationSaveRequest, + OrganizationTarget, OrganizationTargets, OrganizationWorkflow, +}; pub use event::{ AgentChangedEvent, AgentRemovedEvent, DaemonShuttingDownEvent, GitStatusChangedEvent, ProjectChangedEvent, ProjectRemovedEvent, SessionChangedEvent, SessionDeletedEvent, SessionExitedEvent, SessionOutputEvent, SessionOutputGapEvent, SessionReplayCompleteEvent, SessionStatusChangedEvent, WorktreeChangedEvent, WorktreeRemovedEvent, }; +pub use files::{ + DEFAULT_FILE_LIST_LIMIT, FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, + FilePath, FileReadRequest, FileReadResponse, FileRevision, FileTarget, FileWriteRequest, + FileWriteResponse, MAX_FILE_LIST_LIMIT, MAX_FILE_PATH_BYTES, MAX_FILE_TEXT_BYTES, +}; pub use git_path::GitRelativePath; pub use request::{ AgentCommand, AgentCustomCreateRequest, AgentCustomRemoveRequest, AgentCustomUpdateRequest, diff --git a/crates/core/tests/file_contract.rs b/crates/core/tests/file_contract.rs new file mode 100644 index 0000000..8b7ba8d --- /dev/null +++ b/crates/core/tests/file_contract.rs @@ -0,0 +1,339 @@ +//! File IPC boundary tests: exact Unix identifiers, bounds and optimistic saves. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use cli_master_core::wire::{ + DEFAULT_FILE_LIST_LIMIT, FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, + FilePath, FileReadRequest, FileReadResponse, FileRevision, FileTarget, FileWriteRequest, + FileWriteResponse, MAX_FILE_LIST_LIMIT, MAX_FILE_PATH_BYTES, MAX_FILE_TEXT_BYTES, +}; +use cli_master_core::{ProjectId, SessionId, WorktreeId}; +use serde_json::{Value, json}; + +fn target() -> FileTarget { + FileTarget::Project { + project_id: ProjectId::new(), + } +} + +fn revision() -> FileRevision { + FileRevision::try_new(format!("v1:{}", "0123456789abcdef".repeat(4))).unwrap() +} + +fn path() -> FilePath { + FilePath::try_from_bytes(b"src/editor.rs").unwrap() +} + +fn write_payload(text: &str) -> Value { + json!({ + "target": target(), + "pathBase64": path(), + "text": text, + "expectedRevision": revision(), + }) +} + +#[test] +fn paths_round_trip_exact_unix_bytes_without_display_normalization() { + let paths: &[&[u8]] = &[ + b"src/ordinary.txt", + b"space in name.txt", + "açúcar/日本語.md".as_bytes(), + b"binary-name-\xff\xfe.txt", + b"-option-looking", + b"--", + b"literal\\backslash:colon", + b"C:/still-a-relative-unix-name", + b"line\nbreak\tname", + ]; + for bytes in paths { + let path = FilePath::try_from_bytes(bytes).unwrap(); + let value = serde_json::to_value(&path).unwrap(); + assert_eq!(value, json!(STANDARD.encode(bytes))); + assert_eq!(FilePath::try_new(path.as_str()).unwrap().as_bytes(), *bytes); + assert_eq!(serde_json::from_value::(value).unwrap(), path); + } +} + +#[test] +fn path_boundary_rejects_traversal_and_nul_without_echoing_path_contents() { + let invalid: &[&[u8]] = &[ + b"/absolute", + b"trailing/", + b"empty//component", + b".", + b"..", + b"./child", + b"parent/../child", + b"parent/./child", + b"private-value\0suffix", + ]; + for bytes in invalid { + assert!(FilePath::try_from_bytes(bytes).is_err()); + let error = FilePath::try_new(STANDARD.encode(bytes)).unwrap_err(); + assert!(!error.to_string().contains("private-value")); + assert!(serde_json::from_value::(json!(STANDARD.encode(bytes))).is_err()); + } +} + +#[test] +fn path_limit_counts_decoded_bytes_and_canonical_padding_is_required() { + let maximum = vec![b'x'; MAX_FILE_PATH_BYTES]; + assert_eq!( + FilePath::try_from_bytes(&maximum).unwrap().as_bytes(), + maximum + ); + assert!(FilePath::try_new(STANDARD.encode(&maximum)).is_ok()); + let oversized = vec![b'x'; MAX_FILE_PATH_BYTES + 1]; + assert!(FilePath::try_from_bytes(&oversized).is_err()); + assert!(FilePath::try_new(STANDARD.encode(&oversized)).is_err()); + + for invalid in ["YQ", "YQ=", "YR==", "YWJ=", "YQ==\n", "_w==", "YQ===="] { + assert!(FilePath::try_new(invalid).is_err(), "accepted {invalid:?}"); + } + assert_eq!(FilePath::try_new("/w==").unwrap().as_bytes(), &[0xff]); +} + +#[test] +fn root_is_listable_but_never_a_read_or_write_target() { + let root = FilePath::root(); + assert!(root.is_root()); + assert_eq!(serde_json::to_value(&root).unwrap(), json!("")); + assert_eq!(FilePath::try_new("").unwrap(), root); + assert!(FileListRequest::try_new(target(), root.clone(), None, None).is_ok()); + assert!(FileReadRequest::try_new(target(), root.clone()).is_err()); + assert!(FileWriteRequest::try_new(target(), root, "", revision()).is_err()); + + let payload = json!({ "target": target(), "pathBase64": "" }); + assert!(serde_json::from_value::(payload.clone()).is_ok()); + assert!(serde_json::from_value::(payload).is_err()); + let mut payload = write_payload(""); + payload["pathBase64"] = json!(""); + assert!(serde_json::from_value::(payload).is_err()); +} + +#[test] +fn directory_cursors_are_exact_single_names() { + let cursor = FileName::try_from_bytes(b"-next-\xff.txt").unwrap(); + assert_eq!(cursor.as_bytes(), b"-next-\xff.txt"); + assert_eq!(FileName::try_new(cursor.as_str()).unwrap(), cursor); + assert_eq!( + serde_json::from_value::(json!(cursor)).unwrap(), + cursor + ); + for invalid in [&b""[..], b".", b"..", b"dir/leaf", b"a\0b"] { + assert!(FileName::try_from_bytes(invalid).is_err()); + assert!(FileName::try_new(STANDARD.encode(invalid)).is_err()); + } +} + +#[test] +fn list_defaults_and_limits_are_enforced_at_the_wire_boundary() { + let payload = json!({ "target": target(), "pathBase64": "" }); + let request: FileListRequest = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(request.limit, DEFAULT_FILE_LIST_LIMIT); + assert!(request.after_name_base64.is_none()); + + for limit in [1, MAX_FILE_LIST_LIMIT] { + let mut value = payload.clone(); + value["limit"] = json!(limit); + assert_eq!( + serde_json::from_value::(value) + .unwrap() + .limit, + limit + ); + } + for invalid in [json!(0), json!(201), json!(-1), json!(1.5), json!(65536)] { + let mut value = payload.clone(); + value["limit"] = invalid; + assert!(serde_json::from_value::(value).is_err()); + } + assert!(FileListRequest::try_new(target(), FilePath::root(), Some(0), None).is_err()); + assert!(FileListRequest::try_new(target(), FilePath::root(), Some(201), None).is_err()); + let mut value = payload; + value["afterNameBase64"] = json!(STANDARD.encode(b"dir/leaf")); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn targets_are_registered_ids_and_reject_arbitrary_root_overrides() { + for target in [ + target(), + FileTarget::Session { + session_id: SessionId::new(), + }, + FileTarget::Worktree { + worktree_id: WorktreeId::new(), + }, + ] { + let request = FileReadRequest::try_new(target, path()).unwrap(); + let payload = serde_json::to_value(&request).unwrap(); + assert_eq!( + serde_json::from_value::(payload.clone()).unwrap(), + request + ); + assert!(payload.get("pathBase64").unwrap().is_string()); + assert!(payload.get("cwd").is_none()); + let mut overridden = payload; + overridden["target"]["path"] = json!("/tmp/unregistered"); + assert!(serde_json::from_value::(overridden).is_err()); + } + for invalid in [ + json!({ "kind": "path", "path": "/tmp/unregistered" }), + json!({ "kind": "project", "projectId": "codex" }), + json!({ "kind": "worktree", "worktreeId": "invalid-uuid" }), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn request_payloads_reject_unknown_fields_and_missing_revision() { + let mut read = json!({ "target": target(), "pathBase64": path() }); + read["root"] = json!("/tmp"); + assert!(serde_json::from_value::(read).is_err()); + let mut list = json!({ "target": target(), "pathBase64": "" }); + list["recursive"] = json!(true); + assert!(serde_json::from_value::(list).is_err()); + let mut write = write_payload("content"); + write["force"] = json!(true); + assert!(serde_json::from_value::(write).is_err()); + let mut write = write_payload("content"); + write.as_object_mut().unwrap().remove("expectedRevision"); + assert!(serde_json::from_value::(write).is_err()); +} + +#[test] +fn revision_requires_the_versioned_lowercase_digest_shape() { + let valid = revision(); + assert_eq!( + serde_json::from_value::(json!(valid.as_str())).unwrap(), + valid + ); + for invalid in [ + String::new(), + "v1:".to_owned(), + format!("v1:{}", "a".repeat(63)), + format!("v1:{}", "a".repeat(65)), + format!("v2:{}", "a".repeat(64)), + format!("v1:{}", "A".repeat(64)), + format!("v1:{}", "g".repeat(64)), + format!("v1:{}", "é".repeat(32)), + ] { + assert!(FileRevision::try_new(&invalid).is_err()); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } +} + +#[test] +fn writes_count_utf8_bytes_and_preserve_line_endings_bom_and_empty_text() { + for content in [ + String::new(), + "\u{feff}first\r\nsecond\r\n".to_owned(), + "é".repeat(MAX_FILE_TEXT_BYTES / 2), + ] { + let request = FileWriteRequest::try_new(target(), path(), &content, revision()).unwrap(); + assert_eq!(request.text, content); + assert_eq!( + serde_json::from_value::(write_payload(&content)) + .unwrap() + .text, + content + ); + } + let oversized = format!("{}x", "é".repeat(MAX_FILE_TEXT_BYTES / 2)); + for invalid in [oversized, "before\0after".to_owned()] { + assert!(FileWriteRequest::try_new(target(), path(), &invalid, revision()).is_err()); + assert!(serde_json::from_value::(write_payload(&invalid)).is_err()); + } +} + +#[test] +fn maximum_valid_text_fits_the_existing_ipc_frame_even_with_json_escaping() { + let request = FileWriteRequest::try_new( + target(), + FilePath::try_from_bytes(vec![b'x'; MAX_FILE_PATH_BYTES]).unwrap(), + "\u{1}".repeat(MAX_FILE_TEXT_BYTES), + revision(), + ) + .unwrap(); + let envelope = cli_master_core::RequestEnvelope::v1("file.write", request); + let encoded = serde_json::to_vec(&envelope).unwrap(); + assert!(encoded.len() < 1024 * 1024); +} + +#[test] +fn response_contracts_preserve_byte_identity_and_epoch_ms_fields() { + let entry = FileEntry { + path_base64: FilePath::try_from_bytes(b"raw-\xff.txt").unwrap(), + display_name: "raw-\\xFF.txt".to_owned(), + kind: FileEntryKind::File, + size_bytes: Some(12), + modified_at_ms: None, + }; + let list = FileListResponse { + entries: vec![entry], + next_after_name_base64: Some(FileName::try_from_bytes(b"raw-\xff.txt").unwrap()), + observed_at_ms: 1_788_566_400_123, + }; + let list_json = serde_json::to_value(&list).unwrap(); + assert_eq!(list_json["observedAtMs"], json!(1_788_566_400_123_i64)); + assert!(list_json["entries"][0].get("modifiedAtMs").is_none()); + assert_eq!( + list_json["entries"][0]["pathBase64"], + json!(STANDARD.encode(b"raw-\xff.txt")) + ); + assert_eq!( + serde_json::from_value::(list_json).unwrap(), + list + ); + + let read = FileReadResponse { + path_base64: path(), + text: "é\r\n".to_owned(), + revision: revision(), + size_bytes: 4, + modified_at_ms: Some(1_788_566_400_100), + observed_at_ms: 1_788_566_400_123, + }; + let value = serde_json::to_value(&read).unwrap(); + assert_eq!(value["text"], json!("é\r\n")); + assert_eq!( + serde_json::from_value::(value).unwrap(), + read + ); + + let write = FileWriteResponse { + path_base64: path(), + revision: revision(), + size_bytes: 4, + modified_at_ms: Some(1_788_566_400_200), + written_at_ms: 1_788_566_400_201, + }; + let value = serde_json::to_value(&write).unwrap(); + assert_eq!(value["writtenAtMs"], json!(1_788_566_400_201_i64)); + assert_eq!( + serde_json::from_value::(value).unwrap(), + write + ); +} + +#[test] +fn unsupported_entry_kinds_stay_noneditable_and_text_is_redacted_from_debug() { + assert_eq!( + serde_json::from_value::(json!("future_device")).unwrap(), + FileEntryKind::Other + ); + let text = "sensitive document contents"; + let write = FileWriteRequest::try_new(target(), path(), text, revision()).unwrap(); + assert!(!format!("{write:?}").contains(text)); + let read = FileReadResponse { + path_base64: path(), + text: text.to_owned(), + revision: revision(), + size_bytes: u64::try_from(text.len()).unwrap(), + modified_at_ms: None, + observed_at_ms: 1_788_566_400_123, + }; + assert!(!format!("{read:?}").contains(text)); +} diff --git a/crates/core/tests/organization_contract.rs b/crates/core/tests/organization_contract.rs new file mode 100644 index 0000000..1904184 --- /dev/null +++ b/crates/core/tests/organization_contract.rs @@ -0,0 +1,34 @@ +use cli_master_core::wire::{ + OrganizationEntry, OrganizationGetRequest, OrganizationGetResponse, method, +}; +use serde_json::{Value, json}; + +#[test] +fn fixture_keeps_request_order_and_explicit_defaults() { + let fixture: Value = + serde_json::from_str(include_str!("../../../protocol/fixtures/organization.json")).unwrap(); + let request: OrganizationGetRequest = + serde_json::from_value(fixture["request"].clone()).unwrap(); + let response: OrganizationGetResponse = + serde_json::from_value(fixture["response"].clone()).unwrap(); + assert_eq!( + serde_json::to_value(&response).unwrap(), + fixture["response"] + ); + for (target, entry) in request.targets.as_slice().iter().zip(&response.entries) { + assert_eq!(*target, entry.target); + assert_eq!(*entry, OrganizationEntry::defaults(*target)); + } + let saved: OrganizationEntry = serde_json::from_value(fixture["saved"].clone()).unwrap(); + assert_eq!(serde_json::to_value(saved).unwrap(), fixture["saved"]); +} + +#[test] +fn catalog_registers_functional_metadata_operations_without_process_aliases() { + let catalog: Value = + serde_json::from_str(include_str!("../../../protocol/catalog.json")).unwrap(); + assert_eq!(catalog["methods"], json!(method::ALL)); + assert!(method::is_supported(method::ORGANIZATION_GET)); + assert!(method::is_supported(method::ORGANIZATION_SAVE)); + assert!(!method::is_supported("organization.stop")); +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 475d9d4..13aa856 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" cli-master-core = { path = "../core" } +cli-master-file-metadata = { path = "../file-metadata" } cli-master-git = { path = "../git" } cli-master-session = { path = "../session" } cli-master-storage = { path = "../storage" } @@ -26,6 +27,7 @@ futures-util = { version = "0.3.31", features = ["sink"] } rustix = { version = "1.1.4", features = ["fs", "net", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10.9" thiserror = "2" tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = { version = "0.7", features = ["codec", "rt"] } diff --git a/crates/daemon/src/files/attributes.rs b/crates/daemon/src/files/attributes.rs new file mode 100644 index 0000000..6619878 --- /dev/null +++ b/crates/daemon/src/files/attributes.rs @@ -0,0 +1,82 @@ +//! Only explicitly supported, bounded attributes may survive atomic replacement. + +use std::fs::File; + +use cli_master_core::ApiError; +use rustix::fs::flistxattr; + +use super::error::metadata_unsupported; + +#[derive(Eq, PartialEq)] +pub(super) struct SupportedAttributes { + #[cfg(target_os = "macos")] + provenance: Option>, +} + +#[cfg(target_os = "linux")] +pub(super) fn read_supported(file: &File) -> Result { + let mut names = [0_u8; 1]; + match flistxattr(file, &mut names[..]) { + Ok(0) => Ok(SupportedAttributes {}), + Ok(_) | Err(_) => Err(metadata_unsupported()), + } +} + +#[cfg(target_os = "macos")] +const PROVENANCE_NAME: &str = "com.apple.provenance"; +#[cfg(target_os = "macos")] +const MAX_PROVENANCE_BYTES: usize = 4_096; + +#[cfg(target_os = "macos")] +pub(super) fn read_supported(file: &File) -> Result { + let mut names = [0_u8; PROVENANCE_NAME.len() + 1]; + let length = flistxattr(file, &mut names[..]).map_err(|_| metadata_unsupported())?; + if length == 0 { + return Ok(SupportedAttributes { provenance: None }); + } + // The list must contain exactly this one NUL-terminated name. A longer + // list fails at the syscall bound, including resource forks and ACL xattrs. + if length != names.len() + || &names[..PROVENANCE_NAME.len()] != PROVENANCE_NAME.as_bytes() + || names[PROVENANCE_NAME.len()] != 0 + { + return Err(metadata_unsupported()); + } + let mut value = vec![0_u8; MAX_PROVENANCE_BYTES]; + let length = rustix::fs::fgetxattr(file, PROVENANCE_NAME, &mut value[..]) + .map_err(|_| metadata_unsupported())?; + value.truncate(length); + Ok(SupportedAttributes { + provenance: Some(value), + }) +} + +pub(super) fn preserve(source: &File, destination: &File) -> Result<(), ApiError> { + let original = read_supported(source)?; + let staged = read_supported(destination)?; + if original != staged { + #[cfg(target_os = "macos")] + if let Some(value) = original.provenance.as_ref() { + rustix::fs::fsetxattr( + destination, + PROVENANCE_NAME, + value, + rustix::fs::XattrFlags::empty(), + ) + .map_err(|_| metadata_unsupported())?; + } + // Darwin may report success while retaining an OS-owned provenance + // value. Never infer preservation from the write syscall alone. + if original != read_supported(destination)? { + return Err(metadata_unsupported()); + } + } + verify_preserved(source, destination) +} + +pub(super) fn verify_preserved(source: &File, destination: &File) -> Result<(), ApiError> { + if read_supported(source)? != read_supported(destination)? { + return Err(metadata_unsupported()); + } + Ok(()) +} diff --git a/crates/daemon/src/files/descriptor.rs b/crates/daemon/src/files/descriptor.rs new file mode 100644 index 0000000..012763e --- /dev/null +++ b/crates/daemon/src/files/descriptor.rs @@ -0,0 +1,251 @@ +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::Read; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::{Component, Path}; + +use cli_master_core::ApiError; +use cli_master_core::wire::{FilePath, FileRevision, MAX_FILE_TEXT_BYTES}; +use rustix::fs::{AtFlags, FileType, Mode, OFlags, Stat, fstat, open, openat, statat}; +use sha2::{Digest, Sha256}; + +use super::error::{api, conflict, io_error, target_changed}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(super) struct Identity { + pub device: i128, + pub inode: u128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct Fingerprint { + pub identity: Identity, + pub size: i128, + pub modified_seconds: i128, + pub modified_nanos: i128, + pub changed_seconds: i128, + pub changed_nanos: i128, + pub links: u128, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub flags: u32, +} + +impl Fingerprint { + #[allow( + clippy::useless_conversion, + reason = "Unix stat field widths differ between Linux and macOS" + )] + pub(super) fn from_stat(stat: &Stat) -> Self { + Self { + identity: identity(stat), + size: stat.st_size.into(), + modified_seconds: stat.st_mtime.into(), + modified_nanos: stat.st_mtime_nsec.into(), + changed_seconds: stat.st_ctime.into(), + changed_nanos: stat.st_ctime_nsec.into(), + links: stat.st_nlink.into(), + mode: stat.st_mode.into(), + uid: stat.st_uid, + gid: stat.st_gid, + #[cfg(target_os = "macos")] + flags: stat.st_flags, + #[cfg(not(target_os = "macos"))] + flags: 0, + } + } + + pub(super) fn modified_at_ms(&self) -> Option { + self.modified_seconds + .checked_mul(1_000)? + .checked_add(self.modified_nanos.checked_div(1_000_000)?)? + .try_into() + .ok() + } + + pub(super) fn revision(&self, bytes: &[u8]) -> Result { + let mut hash = Sha256::new(); + hash.update(b"cli-master-file-revision-v1\0"); + hash.update(self.identity.device.to_be_bytes()); + hash.update(self.identity.inode.to_be_bytes()); + for value in [ + self.size, + self.modified_seconds, + self.modified_nanos, + self.changed_seconds, + self.changed_nanos, + ] { + hash.update(value.to_be_bytes()); + } + hash.update(self.links.to_be_bytes()); + for value in [self.mode, self.uid, self.gid, self.flags] { + hash.update(value.to_be_bytes()); + } + hash.update(bytes); + FileRevision::try_new(format!("v1:{:x}", hash.finalize())) + .map_err(|_| api("file_io_error", "The file revision could not be encoded.")) + } +} + +pub(super) fn identity(stat: &Stat) -> Identity { + Identity { + device: stat.st_dev.into(), + inode: stat.st_ino.into(), + } +} + +pub(super) fn directory_flags() -> OFlags { + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC +} + +/// Walk even the registered absolute root by descriptor; an intermediate +/// symlink cannot redirect a root open after canonical-path validation. +pub(super) fn open_root(path: &Path) -> Result { + if !path.is_absolute() || path.canonicalize().map_err(|_| target_changed())? != path { + return Err(target_changed()); + } + let mut directory = open("/", directory_flags(), Mode::empty()).map_err(io_error)?; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = openat(&directory, name, directory_flags(), Mode::empty()) + .map_err(|_| target_changed())?; + } + _ => return Err(target_changed()), + } + } + Ok(directory) +} + +pub(super) fn open_directory(root: &impl AsFd, bytes: &[u8]) -> Result { + let mut directory = openat(root, ".", directory_flags(), Mode::empty()).map_err(io_error)?; + if !bytes.is_empty() { + for component in bytes.split(|byte| *byte == b'/') { + let name = OsStr::from_bytes(component); + let observed = statat(&directory, name, AtFlags::SYMLINK_NOFOLLOW).map_err(io_error)?; + if FileType::from_raw_mode(observed.st_mode) == FileType::Symlink { + return Err(api( + "file_symlink_not_allowed", + "Symbolic links cannot be followed by the editor.", + )); + } + directory = + openat(&directory, name, directory_flags(), Mode::empty()).map_err(io_error)?; + } + } + Ok(directory) +} + +pub(super) fn split_file_path(path: &FilePath) -> Result<(&[u8], OsString), ApiError> { + let bytes = path.as_bytes(); + if bytes.is_empty() { + return Err(super::error::invalid_input()); + } + match bytes.iter().rposition(|byte| *byte == b'/') { + Some(index) => Ok(( + &bytes[..index], + OsString::from_vec(bytes[index + 1..].to_vec()), + )), + None => Ok((&[], OsString::from_vec(bytes.to_vec()))), + } +} + +pub(super) struct TextFile { + pub file: File, + pub fingerprint: Fingerprint, + pub text: String, + pub revision: FileRevision, +} + +pub(super) fn read_leaf(parent: &impl AsFd, name: &OsStr) -> Result { + let observed = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).map_err(io_error)?; + require_regular(&observed)?; + let fd = openat( + parent, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(io_error)?; + let before = fstat(&fd).map_err(io_error)?; + require_regular(&before)?; + let fingerprint = Fingerprint::from_stat(&before); + if fingerprint.size + > i128::try_from(MAX_FILE_TEXT_BYTES).map_err(|_| super::error::invalid_input())? + { + return Err(api( + "file_too_large", + "The selected file exceeds the 128 KiB text limit.", + )); + } + let mut file = File::from(fd); + let mut bytes = Vec::new(); + (&mut file) + .take(u64::try_from(MAX_FILE_TEXT_BYTES + 1).map_err(|_| super::error::invalid_input())?) + .read_to_end(&mut bytes) + .map_err(io_error)?; + if bytes.len() > MAX_FILE_TEXT_BYTES { + return Err(api( + "file_too_large", + "The selected file exceeds the 128 KiB text limit.", + )); + } + let after = Fingerprint::from_stat(&fstat(&file).map_err(io_error)?); + if before.st_ino != observed.st_ino || before.st_dev != observed.st_dev || fingerprint != after + { + return Err(conflict(None)); + } + if bytes.contains(&0) { + return Err(api( + "file_not_text", + "The selected file contains binary data.", + )); + } + let text = String::from_utf8(bytes).map_err(|_| { + api( + "file_not_text", + "The selected file is not valid UTF-8 text.", + ) + })?; + let revision = fingerprint.revision(text.as_bytes())?; + Ok(TextFile { + file, + fingerprint, + text, + revision, + }) +} + +pub(super) fn require_regular(stat: &Stat) -> Result<(), ApiError> { + match FileType::from_raw_mode(stat.st_mode) { + FileType::RegularFile => Ok(()), + FileType::Symlink => Err(api( + "file_symlink_not_allowed", + "Symbolic links cannot be edited.", + )), + _ => Err(api( + "file_not_regular", + "The selected object is not a regular file.", + )), + } +} + +pub(super) fn revalidate_namespace( + root_path: &Path, + root_identity: Identity, + parent_bytes: &[u8], + parent_identity: Identity, +) -> Result<(), ApiError> { + let root = open_root(root_path)?; + if identity(&fstat(&root).map_err(io_error)?) != root_identity { + return Err(target_changed()); + } + let parent = open_directory(&root, parent_bytes).map_err(|_| target_changed())?; + if identity(&fstat(&parent).map_err(io_error)?) != parent_identity { + return Err(target_changed()); + } + Ok(()) +} diff --git a/crates/daemon/src/files/error.rs b/crates/daemon/src/files/error.rs new file mode 100644 index 0000000..3b03d16 --- /dev/null +++ b/crates/daemon/src/files/error.rs @@ -0,0 +1,60 @@ +use cli_master_core::ApiError; +use rustix::io::Errno; + +pub(super) fn io_error(error: impl Into) -> ApiError { + let error = error.into(); + match error.raw_os_error().map(Errno::from_raw_os_error) { + Some(Errno::NOENT) => api("file_not_found", "The selected file no longer exists."), + Some(Errno::NOTDIR) => api( + "file_not_directory", + "A selected path component is not a directory.", + ), + Some(Errno::LOOP) => api( + "file_symlink_not_allowed", + "Symbolic links cannot be followed by the editor.", + ), + Some(Errno::ACCESS | Errno::PERM) => api( + "file_permission_denied", + "The file operation is not permitted.", + ), + _ => api("file_io_error", "The file operation could not complete."), + } +} + +pub(super) fn api(code: &str, message: &str) -> ApiError { + ApiError::new(code, message).with_action( + "Keep the editor draft, refresh the selected file, and retry after resolving the error.", + ) +} + +pub(super) fn target_changed() -> ApiError { + api( + "file_target_changed", + "The registered directory changed or is no longer available for this operation.", + ) +} + +pub(super) fn metadata_unsupported() -> ApiError { + api( + "file_metadata_unsupported", + "This file has metadata that cannot be safely preserved by the editor.", + ) +} + +pub(super) fn invalid_input() -> ApiError { + api( + "invalid_input", + "The file request contains an invalid target, path, text, revision, or limit.", + ) +} + +pub(super) fn conflict(revision: Option<&str>) -> ApiError { + let error = api( + "file_conflict", + "The file changed after it was read. Reload before saving.", + ); + match revision { + Some(revision) => error.with_detail("currentRevision", revision), + None => error, + } +} diff --git a/crates/daemon/src/files/listing.rs b/crates/daemon/src/files/listing.rs new file mode 100644 index 0000000..a39a6b7 --- /dev/null +++ b/crates/daemon/src/files/listing.rs @@ -0,0 +1,168 @@ +use std::ffi::OsStr; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, FilePath, +}; +use rustix::fs::{AtFlags, Dir, FileType, fstat, statat}; +use rustix::io::Errno; + +use super::descriptor::{Fingerprint, identity, open_directory, revalidate_namespace}; +use super::error::{api, io_error, target_changed}; +use super::{FileTargetAccess, LocalFileService, now_ms}; + +const MAX_ENUMERATED_ENTRIES: usize = 10_000; +const MAX_PAGE_BYTES: usize = 512 * 1_024; +const PAGE_OVERHEAD_BYTES: usize = 16 * 1_024; + +pub(super) fn list( + service: &LocalFileService, + request: &FileListRequest, + access: &impl FileTargetAccess, +) -> Result { + let resolved = access.resolve_target(&request.target, false)?; + let (root, root_identity) = service.open_target(&request.target, &resolved)?; + let directory = open_directory(&root, request.path_base64.as_bytes())?; + let directory_identity = identity(&fstat(&directory).map_err(io_error)?); + let names = enumerate_names(&directory)?; + let mut entries = Vec::new(); + let mut last_name = None; + let mut next_after_name_base64 = None; + let mut page_bytes = PAGE_OVERHEAD_BYTES; + for name in names { + if request + .after_name_base64 + .as_ref() + .is_some_and(|cursor| name.as_slice() <= cursor.as_bytes()) + { + continue; + } + let stat = match statat( + &directory, + OsStr::from_bytes(&name), + AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(stat) => stat, + Err(Errno::NOENT) => continue, + Err(error) => return Err(io_error(error)), + }; + let mut path = request.path_base64.as_bytes().to_vec(); + if !path.is_empty() { + path.push(b'/'); + } + path.extend_from_slice(&name); + let kind = match FileType::from_raw_mode(stat.st_mode) { + FileType::RegularFile => FileEntryKind::File, + FileType::Directory => FileEntryKind::Directory, + FileType::Symlink => FileEntryKind::Symlink, + _ => FileEntryKind::Other, + }; + let entry = FileEntry { + path_base64: FilePath::try_from_bytes(path).map_err(|_| { + api( + "file_listing_too_large", + "A directory entry exceeds the supported relative-path limit.", + ) + })?, + display_name: display_name(&name), + kind, + size_bytes: if kind == FileEntryKind::File { + u64::try_from(stat.st_size).ok() + } else { + None + }, + modified_at_ms: Fingerprint::from_stat(&stat).modified_at_ms(), + }; + let entry_bytes = serde_json::to_vec(&entry) + .map_err(|_| api("file_io_error", "The directory entry could not be encoded."))? + .len() + + 1; + if entries.len() >= usize::from(request.limit) || page_bytes + entry_bytes > MAX_PAGE_BYTES + { + next_after_name_base64 = last_name; + break; + } + page_bytes += entry_bytes; + entries.push(entry); + last_name = Some(FileName::try_from_bytes(name).map_err(|_| { + api( + "file_io_error", + "The directory cursor could not be encoded.", + ) + })?); + } + if access.resolve_target(&request.target, false)?.root != resolved.root { + return Err(target_changed()); + } + revalidate_namespace( + &resolved.root, + root_identity, + request.path_base64.as_bytes(), + directory_identity, + )?; + Ok(FileListResponse { + entries, + next_after_name_base64, + observed_at_ms: now_ms()?, + }) +} + +fn enumerate_names(directory: &OwnedFd) -> Result>, ApiError> { + let mut names = Vec::new(); + for entry in Dir::read_from(directory).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry.file_name().to_bytes(); + if matches!(name, b"." | b"..") { + continue; + } + if names.len() == MAX_ENUMERATED_ENTRIES { + return Err(api( + "file_listing_too_large", + "This directory exceeds the 10,000-entry enumeration limit.", + )); + } + names.push(name.to_vec()); + } + names.sort_unstable(); + names.dedup(); + Ok(names) +} + +/// Valid Unicode stays readable; undecodable/control bytes are explicit escapes. +fn display_name(bytes: &[u8]) -> String { + let mut display = String::new(); + let mut remaining = bytes; + while !remaining.is_empty() { + match std::str::from_utf8(remaining) { + Ok(text) => { + append_text(&mut display, text); + break; + } + Err(error) => { + let (valid, invalid) = remaining.split_at(error.valid_up_to()); + if let Ok(text) = std::str::from_utf8(valid) { + append_text(&mut display, text); + } + let invalid_length = error.error_len().unwrap_or(invalid.len()); + for byte in &invalid[..invalid_length] { + use std::fmt::Write; + let _ = write!(display, "\\x{byte:02x}"); + } + remaining = &invalid[invalid_length..]; + } + } + } + display +} + +fn append_text(display: &mut String, text: &str) { + for character in text.chars() { + if character.is_control() { + display.extend(character.escape_default()); + } else { + display.push(character); + } + } +} diff --git a/crates/daemon/src/files/metadata.rs b/crates/daemon/src/files/metadata.rs new file mode 100644 index 0000000..e85e6f9 --- /dev/null +++ b/crates/daemon/src/files/metadata.rs @@ -0,0 +1,79 @@ +//! Metadata that this atomic-replacement slice can preserve without elevation. + +use std::fs::File; + +use cli_master_core::ApiError; +use rustix::fs::{Gid, Mode, Uid, fchmod, fchown, fstat}; + +use super::attributes; +use super::descriptor::Fingerprint; +use super::error::metadata_unsupported; + +pub(super) fn inspect(file: &File, fingerprint: &Fingerprint) -> Result<(), ApiError> { + // Replacing a multiply-linked inode would silently disconnect its siblings. + // Special mode bits and platform flags are outside this plain-text slice. + if fingerprint.links != 1 || fingerprint.mode & 0o7000 != 0 || fingerprint.flags != 0 { + return Err(metadata_unsupported()); + } + attributes::read_supported(file)?; + inspect_platform(file) +} + +pub(super) fn apply( + source: &File, + destination: &File, + fingerprint: &Fingerprint, +) -> Result<(), ApiError> { + // Source metadata is checked again to observe ACL/xattr changes during staging. + inspect(source, fingerprint)?; + let current = fstat(destination).map_err(|_| metadata_unsupported())?; + if current.st_uid != fingerprint.uid || current.st_gid != fingerprint.gid { + fchown( + destination, + Some(Uid::from_raw(fingerprint.uid)), + Some(Gid::from_raw(fingerprint.gid)), + ) + .map_err(|_| metadata_unsupported())?; + } + #[allow( + clippy::useless_conversion, + reason = "RawMode is u16 on macOS and u32 on Linux" + )] + let permissions = rustix::fs::RawMode::try_from(fingerprint.mode & 0o777) + .map_err(|_| metadata_unsupported())?; + fchmod(destination, Mode::from_raw_mode(permissions)).map_err(|_| metadata_unsupported())?; + let copied = Fingerprint::from_stat(&fstat(destination).map_err(|_| metadata_unsupported())?); + if copied.uid != fingerprint.uid + || copied.gid != fingerprint.gid + || copied.mode & 0o7777 != fingerprint.mode & 0o7777 + { + return Err(metadata_unsupported()); + } + attributes::preserve(source, destination)?; + // A parent may have supplied inherited ACLs or attributes to the new inode. + // Never publish an inode whose access policy differs silently from the old file. + inspect(destination, &copied) +} + +#[cfg(target_os = "macos")] +fn inspect_platform(file: &File) -> Result<(), ApiError> { + if cli_master_file_metadata::has_extended_acl(file).map_err(|_| metadata_unsupported())? { + return Err(metadata_unsupported()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn inspect_platform(file: &File) -> Result<(), ApiError> { + use rustix::fs::ioctl_getflags; + use rustix::io::Errno; + + // EXTENTS describes the regular ext4 allocation representation, not an + // authored inode policy. Every other flag requires explicit preservation. + const EXTENTS: u32 = 0x0008_0000; + match ioctl_getflags(file) { + Ok(flags) if flags.bits() & !EXTENTS == 0 => Ok(()), + Err(Errno::NOTTY | Errno::OPNOTSUPP) => Ok(()), + _ => Err(metadata_unsupported()), + } +} diff --git a/crates/daemon/src/files/mod.rs b/crates/daemon/src/files/mod.rs new file mode 100644 index 0000000..6e021c9 --- /dev/null +++ b/crates/daemon/src/files/mod.rs @@ -0,0 +1,188 @@ +//! Descriptor-relative local text files; all roots come from daemon metadata. + +mod attributes; +mod descriptor; +mod error; +mod listing; +mod metadata; +mod write; + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + FileListRequest, FileReadRequest, FileReadResponse, FileTarget, FileWriteRequest, + FileWriteResponse, method, +}; +use rustix::fs::fstat; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use descriptor::{ + Identity, identity, open_directory, open_root, read_leaf, revalidate_namespace, split_file_path, +}; +use error::{api, invalid_input, io_error, target_changed}; + +/// A root obtained from a currently registered project, session or worktree. +pub(crate) struct ResolvedFileTarget { + pub(crate) root: PathBuf, +} + +/// Runtime authority supplies metadata resolution and its worktree-removal lease. +pub(crate) trait FileTargetAccess { + fn resolve_target( + &self, + target: &FileTarget, + write: bool, + ) -> Result; + + fn with_write_lease( + &self, + target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result; +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct MutationKey { + parent: Identity, + name: Vec, +} + +#[derive(Default)] +pub(crate) struct LocalFileService { + roots: Mutex>, + mutations: Mutex>>>, +} + +impl LocalFileService { + pub(crate) fn dispatch( + &self, + method: &str, + payload: Value, + access: &impl FileTargetAccess, + ) -> Result { + match method { + method::FILE_LIST => { + let request: FileListRequest = decode(payload)?; + encode(listing::list(self, &request, access)?) + } + method::FILE_READ => { + let request: FileReadRequest = decode(payload)?; + encode(self.read(&request, access)?) + } + method::FILE_WRITE => { + let request: FileWriteRequest = decode(payload)?; + encode(self.write(&request, access)?) + } + _ => Err(api( + "method_not_found", + "The requested file operation is unknown.", + )), + } + } + + /// Shared safe reader for explicitly selected, registered S3 capabilities. + pub(crate) fn read( + &self, + request: &FileReadRequest, + access: &impl FileTargetAccess, + ) -> Result { + let resolved = access.resolve_target(&request.target, false)?; + let (root, root_identity) = self.open_target(&request.target, &resolved)?; + let (parent_bytes, name) = split_file_path(&request.path_base64)?; + let parent = open_directory(&root, parent_bytes)?; + let parent_identity = identity(&fstat(&parent).map_err(io_error)?); + let observed = read_leaf(&parent, &name)?; + let current = access.resolve_target(&request.target, false)?; + if current.root != resolved.root { + return Err(target_changed()); + } + revalidate_namespace(&resolved.root, root_identity, parent_bytes, parent_identity)?; + Ok(FileReadResponse { + path_base64: request.path_base64.clone(), + size_bytes: u64::try_from(observed.text.len()).map_err(|_| invalid_input())?, + modified_at_ms: observed.fingerprint.modified_at_ms(), + text: observed.text, + revision: observed.revision, + observed_at_ms: now_ms()?, + }) + } + + pub(crate) fn write( + &self, + request: &FileWriteRequest, + access: &impl FileTargetAccess, + ) -> Result { + write::save(self, request, access, &write::WriteFaults::default()) + } + + fn open_target( + &self, + target: &FileTarget, + resolved: &ResolvedFileTarget, + ) -> Result<(std::os::fd::OwnedFd, Identity), ApiError> { + let root = open_root(&resolved.root)?; + let observed = identity(&fstat(&root).map_err(io_error)?); + let mut roots = self.roots.lock().map_err(|_| { + api( + "file_io_error", + "The file service state could not be locked.", + ) + })?; + let key = format!("{target:?}"); + if let Some((previous_path, previous_identity)) = roots.get(&key) { + if *previous_identity != observed || *previous_path != resolved.root { + return Err(target_changed()); + } + } else { + roots.insert(key, (resolved.root.clone(), observed)); + } + Ok((root, observed)) + } + + fn mutation(&self, key: MutationKey) -> Result>, ApiError> { + let mut mutations = self.mutations.lock().map_err(|_| { + api( + "file_io_error", + "The file mutation registry could not be locked.", + ) + })?; + mutations.retain(|_, lock| lock.strong_count() != 0); + if let Some(lock) = mutations.get(&key).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(Mutex::new(())); + mutations.insert(key, Arc::downgrade(&lock)); + Ok(lock) + } +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|_| invalid_input()) +} + +fn encode(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|_| api("file_io_error", "The file response could not be encoded.")) +} + +fn now_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .ok_or_else(|| { + api( + "file_io_error", + "The current file-operation timestamp is unavailable.", + ) + }) +} diff --git a/crates/daemon/src/files/tests.rs b/crates/daemon/src/files/tests.rs new file mode 100644 index 0000000..d922758 --- /dev/null +++ b/crates/daemon/src/files/tests.rs @@ -0,0 +1,313 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; + +use cli_master_core::wire::{FilePath, FileReadRequest, FileTarget, FileWriteRequest}; +use cli_master_core::{ApiError, ProjectId}; +use tempfile::TempDir; + +use super::write::{WriteFaults, save}; +use super::{FileTargetAccess, LocalFileService, ResolvedFileTarget}; + +struct Access { + root: PathBuf, +} + +impl FileTargetAccess for Access { + fn resolve_target( + &self, + _target: &FileTarget, + _write: bool, + ) -> Result { + Ok(ResolvedFileTarget { + root: self.root.clone(), + }) + } + + fn with_write_lease( + &self, + _target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result { + if self.root != expected_root { + return Err(super::error::target_changed()); + } + operation() + } +} + +struct Fixture { + _directory: TempDir, + access: Access, + target: FileTarget, + service: LocalFileService, +} + +impl Fixture { + fn new() -> Self { + let directory = TempDir::new().unwrap(); + let root = directory.path().join("project"); + fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); + fs::write(root.join("file.txt"), "original\r\n").unwrap(); + Self { + _directory: directory, + access: Access { root }, + target: FileTarget::Project { + project_id: ProjectId::new(), + }, + service: LocalFileService::default(), + } + } + + fn write_request(&self, path: &[u8], text: &str) -> FileWriteRequest { + let path = FilePath::try_from_bytes(path).unwrap(); + let read = self + .service + .read( + &FileReadRequest::try_new(self.target, path.clone()).unwrap(), + &self.access, + ) + .unwrap(); + FileWriteRequest::try_new(self.target, path, text, read.revision).unwrap() + } + + fn staged_files(&self) -> usize { + fs::read_dir(&self.access.root) + .unwrap() + .filter(|entry| { + entry + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".cli-master-save-") + }) + .count() + } +} + +#[test] +fn observed_external_write_before_rename_preserves_external_bytes_and_cleans_temp() { + let fixture = Fixture::new(); + let request = fixture.write_request(b"file.txt", "our draft"); + let changed_path = fixture.access.root.join("file.txt"); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + after_staging: Some(Box::new(move || { + fs::write(&changed_path, "external edit").unwrap(); + })), + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_conflict"); + assert_eq!( + fs::read(fixture.access.root.join("file.txt")).unwrap(), + b"external edit" + ); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn applied_write_reports_durability_uncertain_without_a_second_implicit_write() { + let fixture = Fixture::new(); + let request = fixture.write_request(b"file.txt", "applied once\r\n"); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + fail_after_rename: true, + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_durability_uncertain"); + let encoded = serde_json::to_value(&error).unwrap(); + assert_eq!(encoded["details"]["writeApplied"], true); + assert!( + encoded["details"]["currentRevision"] + .as_str() + .unwrap() + .starts_with("v1:") + ); + assert_eq!( + fs::read(fixture.access.root.join("file.txt")).unwrap(), + b"applied once\r\n" + ); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn relocated_parent_aborts_before_publication_and_cleans_only_its_temp() { + let fixture = Fixture::new(); + fs::create_dir(fixture.access.root.join("folder")).unwrap(); + fs::write(fixture.access.root.join("folder/file.txt"), "original").unwrap(); + let request = fixture.write_request(b"folder/file.txt", "draft"); + let root = fixture.access.root.clone(); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + after_staging: Some(Box::new(move || { + fs::rename(root.join("folder"), root.join("relocated")).unwrap(); + fs::create_dir(root.join("folder")).unwrap(); + fs::write(root.join("folder/file.txt"), "replacement root").unwrap(); + })), + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_target_changed"); + assert_eq!( + fs::read(fixture.access.root.join("relocated/file.txt")).unwrap(), + b"original" + ); + assert_eq!( + fs::read(fixture.access.root.join("folder/file.txt")).unwrap(), + b"replacement root" + ); + assert_eq!( + fs::read_dir(fixture.access.root.join("relocated")) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn extended_attributes_are_rejected_without_losing_the_original_inode() { + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + let file = fs::File::open(&path).unwrap(); + #[cfg(target_os = "linux")] + let attribute = "user.cli_master_test"; + #[cfg(target_os = "macos")] + let attribute = "com.cli-master.test"; + rustix::fs::fsetxattr( + &file, + attribute, + b"retain attribute", + rustix::fs::XattrFlags::empty(), + ) + .unwrap(); + let request = fixture.write_request(b"file.txt", "draft"); + let before = rustix::fs::fstat(&file).unwrap(); + let error = fixture + .service + .write(&request, &fixture.access) + .unwrap_err(); + assert_eq!(error.code, "file_metadata_unsupported"); + let after = rustix::fs::fstat(fs::File::open(path).unwrap()).unwrap(); + assert_eq!(before.st_ino, after.st_ino); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn mode_and_line_endings_are_preserved_and_revision_survives_new_service() { + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let request = fixture.write_request(b"file.txt", "\u{feff}Olá\r\n"); + let written = fixture.service.write(&request, &fixture.access).unwrap(); + let fresh = LocalFileService::default(); + let read = fresh + .read( + &FileReadRequest::try_new(fixture.target, request.path_base64).unwrap(), + &fixture.access, + ) + .unwrap(); + assert_eq!(read.revision, written.revision); + assert_eq!(read.text, request.text); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_provenance_and_complete_attribute_list_survive_replacement() { + fn snapshot(file: &fs::File) -> (Vec, Option>) { + let mut names = vec![0_u8; 1_024]; + let length = rustix::fs::flistxattr(file, &mut names[..]).unwrap(); + names.truncate(length); + let provenance = if names + .split(|byte| *byte == 0) + .any(|name| name == b"com.apple.provenance") + { + let mut value = vec![0_u8; 4_096]; + let length = + rustix::fs::fgetxattr(file, "com.apple.provenance", &mut value[..]).unwrap(); + value.truncate(length); + Some(value) + } else { + None + }; + (names, provenance) + } + + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + let original = fs::File::open(&path).unwrap(); + let before = snapshot(&original); + let request = fixture.write_request(b"file.txt", "preserve provenance\r\n"); + fixture.service.write(&request, &fixture.access).unwrap(); + let replacement = fs::File::open(path).unwrap(); + assert_ne!( + rustix::fs::fstat(&original).unwrap().st_ino, + rustix::fs::fstat(&replacement).unwrap().st_ino + ); + assert_eq!(snapshot(&replacement), before); +} + +#[test] +fn symlink_leaf_is_rejected_before_reading_outside() { + let fixture = Fixture::new(); + let outside = TempDir::new().unwrap(); + fs::write(outside.path().join("secret.txt"), "outside").unwrap(); + let request = fixture.write_request(b"file.txt", "draft"); + fs::remove_file(fixture.access.root.join("file.txt")).unwrap(); + symlink( + outside.path().join("secret.txt"), + fixture.access.root.join("file.txt"), + ) + .unwrap(); + let error = fixture + .service + .write(&request, &fixture.access) + .unwrap_err(); + assert_eq!(error.code, "file_symlink_not_allowed"); + assert_eq!( + fs::read(outside.path().join("secret.txt")).unwrap(), + b"outside" + ); +} + +#[test] +fn previously_observed_root_replaced_by_another_directory_is_rejected() { + let fixture = Fixture::new(); + fixture.write_request(b"file.txt", "draft"); + let old = fixture.access.root.with_file_name("original-project"); + fs::rename(&fixture.access.root, &old).unwrap(); + fs::create_dir(&fixture.access.root).unwrap(); + fs::write( + fixture.access.root.join("file.txt"), + "replacement directory", + ) + .unwrap(); + let request = FileReadRequest::try_new( + fixture.target, + FilePath::try_from_bytes(b"file.txt").unwrap(), + ) + .unwrap(); + let error = fixture.service.read(&request, &fixture.access).unwrap_err(); + assert_eq!(error.code, "file_target_changed"); + assert_eq!(fs::read(old.join("file.txt")).unwrap(), b"original\r\n"); +} diff --git a/crates/daemon/src/files/write.rs b/crates/daemon/src/files/write.rs new file mode 100644 index 0000000..9b06f6d --- /dev/null +++ b/crates/daemon/src/files/write.rs @@ -0,0 +1,206 @@ +use std::ffi::OsString; +use std::fs::File; +use std::io::Write; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; + +use cli_master_core::ApiError; +use cli_master_core::wire::{FileWriteRequest, FileWriteResponse, MAX_FILE_TEXT_BYTES}; +use rustix::fs::{AtFlags, Mode, OFlags, fstat, fsync, openat, renameat, statat, unlinkat}; + +use super::descriptor::{ + Fingerprint, identity, open_directory, read_leaf, revalidate_namespace, split_file_path, +}; +use super::error::{api, conflict, invalid_input, io_error, target_changed}; +use super::{FileTargetAccess, LocalFileService, MutationKey, attributes, metadata, now_ms}; + +#[derive(Default)] +pub(super) struct WriteFaults { + #[cfg(test)] + pub after_staging: Option>, + #[cfg(test)] + pub fail_after_rename: bool, +} + +#[allow( + clippy::unused_self, + reason = "fault hooks are inert outside deterministic tests" +)] +impl WriteFaults { + fn after_staging(&self) { + #[cfg(test)] + if let Some(hook) = &self.after_staging { + hook(); + } + } + + #[allow( + clippy::unnecessary_wraps, + reason = "tests inject an error after publication to verify durability reporting" + )] + fn after_rename(&self) -> Result<(), ApiError> { + #[cfg(test)] + if self.fail_after_rename { + return Err(api( + "file_io_error", + "Injected directory durability failure.", + )); + } + Ok(()) + } +} + +pub(super) fn save( + service: &LocalFileService, + request: &FileWriteRequest, + access: &impl FileTargetAccess, + faults: &WriteFaults, +) -> Result { + if request.text.len() > MAX_FILE_TEXT_BYTES || request.text.contains('\0') { + return Err(invalid_input()); + } + let resolved = access.resolve_target(&request.target, true)?; + let (root, root_identity) = service.open_target(&request.target, &resolved)?; + let (parent_bytes, name) = split_file_path(&request.path_base64)?; + let parent = open_directory(&root, parent_bytes)?; + let parent_identity = identity(&fstat(&parent).map_err(io_error)?); + let lock = service.mutation(MutationKey { + parent: parent_identity, + name: name.as_bytes().to_vec(), + })?; + let _mutation = lock + .lock() + .map_err(|_| api("file_io_error", "The file mutation lock is unavailable."))?; + let original = read_leaf(&parent, &name)?; + if original.revision != request.expected_revision { + return Err(conflict(Some(original.revision.as_str()))); + } + metadata::inspect(&original.file, &original.fingerprint)?; + let mut temporary = TemporaryFile::new(&parent)?; + temporary + .file + .write_all(request.text.as_bytes()) + .map_err(io_error)?; + temporary.file.flush().map_err(io_error)?; + metadata::apply(&original.file, &temporary.file, &original.fingerprint)?; + fsync(&temporary.file).map_err(io_error)?; + let staged_fingerprint = Fingerprint::from_stat(&fstat(&temporary.file).map_err(io_error)?); + faults.after_staging(); + + access.with_write_lease(&request.target, &resolved.root, || { + let current = read_leaf(&parent, &name).map_err(|error| match error.code.as_str() { + "file_not_found" + | "file_not_regular" + | "file_symlink_not_allowed" + | "file_too_large" + | "file_not_text" => conflict(None), + _ => error, + })?; + if current.revision != request.expected_revision { + return Err(conflict(Some(current.revision.as_str()))); + } + metadata::inspect(¤t.file, ¤t.fingerprint)?; + temporary.validate_ownership()?; + let staged_now = Fingerprint::from_stat(&fstat(&temporary.file).map_err(io_error)?); + if staged_now != staged_fingerprint { + return Err(conflict(None)); + } + metadata::inspect(&temporary.file, &staged_now)?; + attributes::verify_preserved(¤t.file, &temporary.file)?; + revalidate_namespace(&resolved.root, root_identity, parent_bytes, parent_identity)?; + renameat(&parent, &temporary.name, &parent, &name).map_err(io_error)?; + temporary.published = true; + + let published = Fingerprint::from_stat( + &fstat(&temporary.file).map_err(|_| durability_uncertain(None))?, + ); + let revision = published + .revision(request.text.as_bytes()) + .map_err(|_| durability_uncertain(None))?; + faults + .after_rename() + .map_err(|_| durability_uncertain(Some(revision.as_str())))?; + fsync(&parent).map_err(|_| durability_uncertain(Some(revision.as_str())))?; + let namespace = statat(&parent, &name, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| durability_uncertain(Some(revision.as_str())))?; + let observed = Fingerprint::from_stat(&namespace); + if observed != published { + return Err(durability_uncertain(Some(revision.as_str()))); + } + Ok(FileWriteResponse { + path_base64: request.path_base64.clone(), + revision, + size_bytes: u64::try_from(request.text.len()) + .map_err(|_| durability_uncertain(None))?, + modified_at_ms: published.modified_at_ms(), + written_at_ms: now_ms().map_err(|_| durability_uncertain(None))?, + }) + }) +} + +fn durability_uncertain(revision: Option<&str>) -> ApiError { + let mut error = ApiError::new( + "file_durability_uncertain", + "The replacement was applied, but its final durability could not be confirmed.", + ) + .with_action( + "Keep the draft and re-read the file before retrying; the write may already be present.", + ) + .with_detail("writeApplied", true); + if let Some(revision) = revision { + error = error.with_detail("currentRevision", revision); + } + error +} + +struct TemporaryFile<'a> { + parent: &'a OwnedFd, + name: OsString, + file: File, + identity: super::descriptor::Identity, + published: bool, +} + +impl<'a> TemporaryFile<'a> { + fn new(parent: &'a OwnedFd) -> Result { + let name = OsString::from(format!( + ".cli-master-save-{}", + uuid::Uuid::now_v7().simple() + )); + let fd = openat( + parent, + &name, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + ) + .map_err(io_error)?; + // If identity cannot be established, leave the uniquely named temporary + // file for manual inspection rather than unlinking an unproven name. + let identity = identity(&fstat(&fd).map_err(io_error)?); + let file = File::from(fd); + Ok(Self { + parent, + name, + file, + identity, + published: false, + }) + } + + fn validate_ownership(&self) -> Result<(), ApiError> { + let stat = statat(self.parent, &self.name, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| target_changed())?; + if identity(&stat) != self.identity || stat.st_nlink != 1 { + return Err(target_changed()); + } + Ok(()) + } +} + +impl Drop for TemporaryFile<'_> { + fn drop(&mut self) { + if !self.published && self.validate_ownership().is_ok() { + let _ = unlinkat(self.parent, &self.name, AtFlags::empty()); + } + } +} diff --git a/crates/daemon/src/knowledge/discovery/mod.rs b/crates/daemon/src/knowledge/discovery/mod.rs index ccffd61..a823e45 100644 --- a/crates/daemon/src/knowledge/discovery/mod.rs +++ b/crates/daemon/src/knowledge/discovery/mod.rs @@ -31,6 +31,20 @@ const SCAN_TTL: Duration = Duration::from_secs(300); const MAX_ENTRY_JSON_BYTES: usize = 448 * 1_024; const MAX_ISSUE_JSON_BYTES: usize = 32 * 1_024; const MAX_ISSUES: usize = 32; +// Provider directories are excluded from ordinary project traversal; only +// explicit source specifications enter them. Other names are an explicit +// dependency/VCS policy; build, dist and target may hold project instructions. +const PRUNED_PROJECT_DIRECTORIES: &[&str] = &[ + ".git", + "node_modules", + "vendor", + ".venv", + "venv", + ".codex", + ".agents", + ".claude", + ".cursor", +]; /// Daemon-selected roots; IPC callers cannot override any of these paths. #[derive(Clone, Debug)] @@ -85,7 +99,7 @@ impl DiscoveryService { } } - /// Inventories known globals and project-root locations, without reading text. + /// Inventories known globals and bounded project scopes, without reading text. pub(crate) fn discover( &mut self, project: Option<&Project>, @@ -100,16 +114,7 @@ impl DiscoveryService { let mut scanner = Scanner::new(scan_id); scanner.issue("activation_not_evaluated", None, "Discovery reports known source locations, not whether a native CLI loaded them. Imports, config overrides, frontmatter activation and plugins are not evaluated."); - if project.is_some() { - scanner.issue("project_root_scope", None, - "This inventory covers the registered project root. Rules or skills from a session's other working-directory ancestors are not evaluated."); - } - for spec in source_specs(&self.roots, project) { - if scanner.full() || scanner.remaining_nodes == 0 { - break; - } - scanner.scan_source(&spec); - } + scanner.scan_inventory(&self.roots, project); let now = Instant::now(); self.scans .retain(|scan| now.duration_since(scan.created) < SCAN_TTL); @@ -228,6 +233,7 @@ enum Shape { struct SourceSpec { base: PathBuf, + scope_directory: String, directory: &'static str, provider: KnowledgeProvider, scope: KnowledgeSourceScope, @@ -266,9 +272,9 @@ impl SourceSpec { } } -fn source_specs(roots: &DiscoveryRoots, project: Option<&Project>) -> Vec { - use KnowledgeProvider::{Claude, Codex, Cursor}; - use KnowledgeSourceScope::{Admin, Global, Project as ProjectScope}; +fn global_source_specs(roots: &DiscoveryRoots) -> Vec { + use KnowledgeProvider::{Claude, Codex}; + use KnowledgeSourceScope::{Admin, Global}; let mut specs = Vec::new(); let codex_home = roots .codex_home @@ -278,6 +284,7 @@ fn source_specs(roots: &DiscoveryRoots, project: Option<&Project>) -> Vec) -> Vec) -> Vec Vec { + use KnowledgeProvider::{Claude, Codex, Cursor}; + use KnowledgeSourceScope::Project as ProjectScope; + let mut specs = Vec::new(); + for leaf in ["AGENTS.override.md", "AGENTS.md"] { specs.push(SourceSpec { - base: project.path.clone(), - directory: ".cursor/rules", - provider: Cursor, + base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), + directory: "", + provider: Codex, scope: ProjectScope, - shape: Shape::Rules("mdc"), + shape: Shape::Exact(leaf), }); + } + for (directory, leaf) in [ + ("", "CLAUDE.md"), + (".claude", "CLAUDE.md"), + ("", "CLAUDE.local.md"), + ] { specs.push(SourceSpec { - base: project.path.clone(), - directory: "", - provider: Cursor, + base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), + directory, + provider: Claude, scope: ProjectScope, - shape: Shape::Exact("AGENTS.md"), + shape: Shape::Exact(leaf), }); - add_skills(&mut specs, &project.path, ProjectScope); } - specs.sort_by_key(|spec| match spec.scope { - ProjectScope => 0, - Global => 1, - Admin => 2, + specs.push(SourceSpec { + base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), + directory: ".claude/rules", + provider: Claude, + scope: ProjectScope, + shape: Shape::Rules("md"), + }); + specs.push(SourceSpec { + base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), + directory: ".cursor/rules", + provider: Cursor, + scope: ProjectScope, + shape: Shape::Rules("mdc"), }); + specs.push(SourceSpec { + base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), + directory: "", + provider: Cursor, + scope: ProjectScope, + shape: Shape::Exact("AGENTS.md"), + }); + add_skills(&mut specs, base, ProjectScope, scope_directory); specs } -fn add_skills(specs: &mut Vec, base: &Path, scope: KnowledgeSourceScope) { +fn add_skills( + specs: &mut Vec, + base: &Path, + scope: KnowledgeSourceScope, + scope_directory: &str, +) { for (provider, directory, recursive) in [ (KnowledgeProvider::Codex, ".agents/skills", false), (KnowledgeProvider::Claude, ".claude/skills", false), @@ -372,6 +392,7 @@ fn add_skills(specs: &mut Vec, base: &Path, scope: KnowledgeSourceSc ] { specs.push(SourceSpec { base: base.to_path_buf(), + scope_directory: scope_directory.to_owned(), directory, provider, scope, @@ -404,6 +425,110 @@ impl Scanner { } } + fn scan_inventory(&mut self, roots: &DiscoveryRoots, project: Option<&Project>) { + if project.is_some() { + self.issue("project_subtree_scope", None, + "Discovery inventories known configurations in the registered project subtree. Session working-directory ancestors, repository boundaries and native activation are not evaluated."); + self.issue("project_scan_policy", None, + "Project-root sources are scanned before globals and then descendants, sharing all limits. Descendant traversal skips .git, node_modules, vendor, .venv, venv and .codex; .agents, .claude and .cursor use only their known rules and skills readers."); + } + // Resolve daemon-selected root aliases once. Nested scopes must keep + // their opened capability; reopening absolute paths could follow a link + // that replaced an ordinary directory after enumeration. + let project_directory = project.and_then(|project| self.open_base(&project.path)); + if let (Some(project), Some(directory)) = (project, &project_directory) { + self.scan_project_scope(directory, &project.path, Path::new(""), 0); + } + for spec in global_source_specs(roots) { + if self.full() { + break; + } + self.scan_source(&spec); + } + // A large descendant tree cannot consume the budget before globals. + if let (Some(project), Some(directory)) = (project, &project_directory) { + if !self.full() && self.remaining_nodes > 0 { + self.walk_project(directory, &project.path, Path::new(""), 0); + } + } + } + + fn scan_project_scope( + &mut self, + directory: &Directory, + logical: &Path, + relative: &Path, + depth: usize, + ) { + let Some(relative) = relative.to_str() else { + self.issue("non_utf8_path", None, "A project scope with a non-UTF-8 path was skipped; path bytes were not converted lossily."); + return; + }; + let scope = if relative.is_empty() { "." } else { relative }; + // Exact allowlisted probes remain possible for directory entries already + // collected at the enumeration limit, just like rule/skill candidates. + for spec in project_source_specs(logical, scope) { + if self.full() { + break; + } + self.scan_source_at(directory, &spec, depth); + } + } + + fn walk_project( + &mut self, + directory: &Directory, + logical: &Path, + relative: &Path, + depth: usize, + ) { + for name in self.names(directory, logical) { + if self.full() { + break; + } + if PRUNED_PROJECT_DIRECTORIES + .iter() + .any(|pruned| name == OsStr::new(pruned)) + { + continue; + } + let path = logical.join(&name); + match directory.open_child(&name, false) { + Ok((child, _)) => { + if depth >= MAX_DEPTH { + self.depth_limit(&path); + continue; + } + let scope = relative.join(&name); + self.scan_project_scope(&child, &path, &scope, depth + 1); + if !self.full() && self.remaining_nodes > 0 { + self.walk_project(&child, &path, &scope, depth + 1); + } + } + Err(SafeError::NonRegular | SafeError::Missing) => (), + Err(SafeError::Symlink) => self.issue( + "directory_symlink_skipped", + Some(&path), + "Project-directory symlinks are not followed; only known skill-directory links are supported.", + ), + Err(_) => self.issue( + "directory_unavailable", + Some(&path), + "The project directory could not be opened safely.", + ), + } + } + } + + fn depth_limit(&mut self, path: &Path) { + self.response.truncated = true; + self.issue( + "depth_limit", + Some(path), + "The cumulative source-directory depth limit was reached.", + ); + } + fn full(&mut self) -> bool { if self.response.entries.len() >= MAX_ENTRIES || self.entry_bytes >= MAX_ENTRY_JSON_BYTES { self.response.truncated = true; @@ -431,28 +556,44 @@ impl Scanner { } } - fn scan_source(&mut self, spec: &SourceSpec) { - let logical = spec.base.join(spec.directory); - let (mut directory, _) = match Directory::open_absolute(&spec.base, true) { - Ok(value) => value, - Err(SafeError::Missing) => return, + fn open_base(&mut self, path: &Path) -> Option { + match Directory::open_absolute(path, true) { + Ok((directory, _)) => Some(directory), + Err(SafeError::Missing) => None, Err(_) => { self.issue( "source_unavailable", - Some(&logical), + Some(path), "The configured source directory could not be opened safely.", ); - return; + None } - }; + } + } + + fn scan_source(&mut self, spec: &SourceSpec) { + if let Some(directory) = self.open_base(&spec.base) { + self.scan_source_at(&directory, spec, 0); + } + } + + fn scan_source_at(&mut self, base: &Directory, spec: &SourceSpec, mut depth: usize) { + let logical = spec.base.join(spec.directory); + let mut opened = None; let mut linked = false; for component in Path::new(spec.directory).components() { + let directory = opened.as_ref().unwrap_or(base); match directory.open_child( component.as_os_str(), spec.kind() == KnowledgeSourceKind::Skill, ) { Ok((child, via_link)) => { - directory = child; + if depth >= MAX_DEPTH { + self.depth_limit(&logical); + return; + } + depth += 1; + opened = Some(child); linked |= via_link; } Err(SafeError::Missing) => return, @@ -462,20 +603,22 @@ impl Scanner { } } } + let directory = opened.as_ref().unwrap_or(base); match spec.shape { Shape::Exact(leaf) => self.add_candidate( - &directory, + directory, OsStr::new(leaf), spec, &logical.join(leaf), linked, ), - Shape::Rules(extension) => { - self.walk_rules(&directory, spec, &logical, extension, 0, linked); + Shape::Rules(extension) if self.remaining_nodes > 0 => { + self.walk_rules(directory, spec, &logical, extension, depth, linked); } - Shape::Skills { recursive } => { - self.walk_skills(&directory, spec, &logical, recursive, 0, linked, &[]); + Shape::Skills { recursive } if self.remaining_nodes > 0 => { + self.walk_skills(directory, spec, &logical, recursive, depth, linked, &[]); } + Shape::Rules(_) | Shape::Skills { .. } => (), } } @@ -523,12 +666,7 @@ impl Scanner { continue; } if depth >= MAX_DEPTH { - self.response.truncated = true; - self.issue( - "depth_limit", - Some(&path), - "The rule-directory depth limit was reached.", - ); + self.depth_limit(&path); } else { self.walk_rules(&child, spec, &path, extension, depth + 1, linked); } @@ -580,6 +718,10 @@ impl Scanner { ); continue; } + if depth >= MAX_DEPTH { + self.depth_limit(&path); + continue; + } self.add_candidate( &child, OsStr::new("SKILL.md"), @@ -588,24 +730,15 @@ impl Scanner { linked || via_link, ); if recursive && self.remaining_nodes > 0 { - if depth >= MAX_DEPTH { - self.response.truncated = true; - self.issue( - "depth_limit", - Some(&path), - "The skill-directory depth limit was reached.", - ); - } else { - self.walk_skills( - &child, - spec, - &path, - true, - depth + 1, - linked || via_link, - &ancestors, - ); - } + self.walk_skills( + &child, + spec, + &path, + true, + depth + 1, + linked || via_link, + &ancestors, + ); } } Err(SafeError::NonRegular | SafeError::Missing) => (), @@ -661,11 +794,7 @@ impl Scanner { scope: spec.scope, source_path: path.to_owned(), name: name.to_owned(), - scope_directory: if spec.scope == KnowledgeSourceScope::Project { - ".".to_owned() - } else { - String::new() - }, + scope_directory: spec.scope_directory.clone(), precedence_hint: spec.precedence().to_owned(), via_symlink: linked, availability: candidate.availability, diff --git a/crates/daemon/src/knowledge/discovery/project_tests.rs b/crates/daemon/src/knowledge/discovery/project_tests.rs new file mode 100644 index 0000000..eb0eb5c --- /dev/null +++ b/crates/daemon/src/knowledge/discovery/project_tests.rs @@ -0,0 +1,363 @@ +use super::*; + +#[test] +fn nested_formats_keep_owner_scope_and_provider_identity() { + let mut fixture = Fixture::new(); + write(fixture.project.path.join("AGENTS.md"), b"Root instructions"); + let nested = fixture.project.path.join("packages/api"); + for relative in [ + "AGENTS.md", + "AGENTS.override.md", + "CLAUDE.md", + "CLAUDE.local.md", + ".claude/CLAUDE.md", + ".claude/rules/security/request.md", + ".cursor/rules/nested/request.mdc", + ".agents/skills/shared/SKILL.md", + ".claude/skills/local/SKILL.md", + ".cursor/skills/group/nested/SKILL.md", + ] { + write(nested.join(relative), b"Nested source\n@private.txt"); + } + for relative in [ + ".cursor/rules/ignored.md", + ".claude/settings.json", + ".claude/skills/local/AGENTS.md", + "private.txt", + ] { + write(nested.join(relative), b"PRIVATE_CONFIG_SENTINEL"); + } + + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert!(!scan.truncated); + assert_eq!(scan.entries.len(), 14); + assert_eq!( + scan.entries + .iter() + .filter(|entry| entry.scope_directory == ".") + .count(), + 2 + ); + let nested_entries = scan + .entries + .iter() + .filter(|entry| entry.scope_directory != "."); + for entry in nested_entries { + assert_eq!(entry.scope, KnowledgeSourceScope::Project); + assert_eq!(entry.scope_directory, "packages/api"); + assert!(!entry.via_symlink); + let read = fixture + .service + .read(&KnowledgeReadRequest { + scan_id: scan.scan_id, + entry_id: entry.entry_id, + }) + .unwrap(); + assert_eq!(read.entry, *entry); + assert_eq!(read.content, "Nested source\n@private.txt"); + } + let shared = scan + .entries + .iter() + .filter(|entry| entry.name == "shared") + .collect::>(); + assert_eq!(shared.len(), 2); + assert_ne!(shared[0].entry_id, shared[1].entry_id); + assert_ne!(shared[0].provider, shared[1].provider); + assert_eq!(shared[0].source_path, shared[1].source_path); +} + +#[test] +fn pruning_is_exact_and_does_not_hide_build_or_registered_roots() { + let mut fixture = Fixture::new(); + // The explicitly registered root remains eligible even with a pruned name. + fixture.project.path = fixture.project.path.join("vendor"); + write(fixture.project.path.join("CLAUDE.md"), b"Selected root"); + for name in [".git", "node_modules", "vendor", ".venv", "venv", ".codex"] { + write( + fixture.project.path.join(name).join("cache/AGENTS.md"), + b"Pruned", + ); + } + for name in ["vendor-tools", "build", "dist", "target"] { + write( + fixture.project.path.join(name).join("CLAUDE.md"), + b"Valid scope", + ); + } + for name in [".agents", ".claude", ".cursor"] { + write( + fixture + .project + .path + .join(name) + .join("skills/tool/AGENTS.md"), + b"Not a project scope", + ); + } + write( + fixture.project.path.join(".agents/skills/tool/SKILL.md"), + b"Known skill", + ); + + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert!(!scan.truncated); + assert_eq!(scan.entries.len(), 7); + assert!(scan.entries.iter().all(|entry| entry.name != "AGENTS.md")); + for scope in [".", "vendor-tools", "build", "dist", "target"] { + assert!( + scan.entries + .iter() + .any(|entry| entry.scope_directory == scope) + ); + } + assert_eq!( + scan.entries + .iter() + .filter(|entry| entry.name == "tool") + .count(), + 2 + ); + assert!( + scan.issues + .iter() + .any(|issue| issue.code == "project_scan_policy") + ); +} + +#[test] +fn nested_directory_links_are_skipped_except_known_skill_locations() { + let mut fixture = Fixture::new(); + let outside = fixture.root.path().join("outside"); + write(outside.join("CLAUDE.md"), b"Outside instructions"); + write(outside.join("SKILL.md"), b"Allowed linked skill"); + let nested = fixture.project.path.join("packages/api"); + fs::create_dir_all(nested.join(".claude/skills")).unwrap(); + symlink(&outside, nested.join("escape")).unwrap(); + symlink(&outside, nested.join(".claude/skills/linked")).unwrap(); + symlink(outside.join("CLAUDE.md"), nested.join("CLAUDE.md")).unwrap(); + + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert_eq!(scan.entries.len(), 2); + assert!( + scan.entries + .iter() + .all(|entry| entry.scope_directory == "packages/api") + ); + let linked = select(&scan, "/linked/SKILL.md", KnowledgeProvider::Claude); + let read = fixture.service.read(&linked).unwrap(); + assert!(read.entry.via_symlink); + assert_eq!(read.content, "Allowed linked skill"); + let leaf = select(&scan, "/CLAUDE.md", KnowledgeProvider::Claude); + assert_eq!( + fixture.service.read(&leaf).unwrap_err().code, + "knowledge_source_symlink" + ); + assert!( + scan.issues + .iter() + .any(|issue| issue.code == "directory_symlink_skipped") + ); +} + +#[test] +fn pinned_nested_scope_never_reopens_a_replacement_directory_link() { + let fixture = Fixture::new(); + let nested = fixture.project.path.join("nested"); + write(nested.join("CLAUDE.md"), b"Original pinned directory"); + write(nested.join(".claude/CLAUDE.md"), b"Original pinned child"); + let outside = fixture.root.path().join("outside"); + write(outside.join("AGENTS.md"), b"OUTSIDE_SENTINEL"); + write( + outside.join(".claude/rules/outside.md"), + b"OUTSIDE_SENTINEL", + ); + let (root, _) = Directory::open_absolute(&fixture.project.path, true).unwrap(); + let (pinned, _) = root.open_child(OsStr::new("nested"), false).unwrap(); + fs::rename(&nested, fixture.root.path().join("moved")).unwrap(); + symlink(&outside, &nested).unwrap(); + + let mut scanner = Scanner::new(KnowledgeScanId::new()); + scanner.scan_project_scope(&pinned, &nested, Path::new("nested"), 1); + assert_eq!(scanner.response.entries.len(), 2); + assert!( + scanner + .response + .entries + .iter() + .all(|entry| entry.name == "CLAUDE.md") + ); + assert!( + scanner + .saved + .values() + .all(|source| source.entry.provider == KnowledgeProvider::Claude) + ); +} + +#[test] +fn nested_parent_replacement_invalidates_existing_read_capability() { + let mut fixture = Fixture::new(); + let nested = fixture.project.path.join("packages/api"); + write(nested.join("CLAUDE.md"), b"Original"); + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + let request = select(&scan, "/api/CLAUDE.md", KnowledgeProvider::Claude); + fs::rename(&nested, fixture.root.path().join("moved")).unwrap(); + write(nested.join("CLAUDE.md"), b"Replacement"); + assert_eq!( + fixture.service.read(&request).unwrap_err().code, + "knowledge_source_changed" + ); + + let fresh = fixture.service.discover(Some(&fixture.project)).unwrap(); + let read = fixture + .service + .read(&select(&fresh, "/api/CLAUDE.md", KnowledgeProvider::Claude)) + .unwrap(); + assert_eq!(read.content, "Replacement"); + assert_eq!(read.entry.scope_directory, "packages/api"); +} + +#[test] +fn depth_is_cumulative_across_project_rules_and_skill_directories() { + let mut fixture = Fixture::new(); + let mut scopes = vec![fixture.project.path.clone()]; + for depth in 1..=MAX_DEPTH + 1 { + scopes.push(scopes[depth - 1].join("nested")); + } + for (depth, path, content) in [ + (16, "CLAUDE.md", "Allowed deepest scope"), + (17, "CLAUDE.md", "Beyond project depth"), + (14, ".claude/rules/allowed.md", "Allowed deepest rule"), + (14, ".claude/rules/group/denied.md", "Beyond rule depth"), + ( + 13, + ".claude/skills/allowed/SKILL.md", + "Allowed deepest skill", + ), + (14, ".claude/skills/denied/SKILL.md", "Beyond skill depth"), + ( + 12, + ".cursor/skills/group/allowed/SKILL.md", + "Allowed recursive skill", + ), + ( + 12, + ".cursor/skills/group/deeper/denied/SKILL.md", + "Beyond recursive depth", + ), + ] { + write(scopes[depth].join(path), content.as_bytes()); + } + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert!(scan.truncated); + assert!(scan.issues.iter().any(|issue| issue.code == "depth_limit")); + assert_eq!(scan.entries.len(), 4); + for entry in &scan.entries { + let read = fixture + .service + .read(&KnowledgeReadRequest { + scan_id: scan.scan_id, + entry_id: entry.entry_id, + }) + .unwrap(); + assert!(read.content.starts_with("Allowed")); + } +} + +#[test] +fn enumeration_budget_is_shared_and_keeps_already_collected_scope_candidates() { + let fixture = Fixture::new(); + for name in ["first", "second"] { + write( + fixture.project.path.join(name).join("CLAUDE.md"), + b"Collected scope", + ); + write( + fixture.project.path.join(name).join("deeper/CLAUDE.md"), + b"Not enumerated", + ); + } + write(fixture.home().join(".codex/AGENTS.md"), b"Global"); + let mut scanner = Scanner::new(KnowledgeScanId::new()); + scanner.remaining_nodes = 2; + scanner.scan_inventory(&fixture.service.roots, Some(&fixture.project)); + assert_eq!(scanner.remaining_nodes, 0); + assert!(scanner.response.truncated); + assert_eq!(scanner.response.entries.len(), 3); + assert_eq!( + scanner.response.entries[0].scope, + KnowledgeSourceScope::Global + ); + assert!( + scanner.response.entries[1..] + .iter() + .all(|entry| { matches!(entry.scope_directory.as_str(), "first" | "second") }) + ); + assert!( + scanner + .response + .issues + .iter() + .any(|issue| issue.code == "scan_limit") + ); +} + +#[test] +fn root_and_globals_precede_descendants_under_the_shared_entry_limit() { + let mut fixture = Fixture::new(); + write(fixture.project.path.join("CLAUDE.md"), b"Root"); + write(fixture.home().join(".codex/AGENTS.md"), b"Global"); + for index in 0..MAX_ENTRIES + 4 { + write( + fixture + .project + .path + .join(format!("scopes/scope-{index:04}/CLAUDE.md")), + b"Nested", + ); + } + let scan = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert_eq!(scan.entries.len(), MAX_ENTRIES); + assert!(scan.truncated); + assert_eq!(scan.entries[0].scope_directory, "."); + assert_eq!(scan.entries[1].scope, KnowledgeSourceScope::Global); + assert!( + scan.entries[2..] + .iter() + .all(|entry| entry.scope_directory.starts_with("scopes/")) + ); + assert!(serde_json::to_vec(&scan).unwrap().len() < 512 * 1_024); +} + +#[test] +fn subtree_inventory_does_not_infer_ancestors_or_walk_the_global_home() { + let mut fixture = Fixture::new(); + write( + fixture.root.path().join("CLAUDE.md"), + b"Ancestor is outside this inventory", + ); + write( + fixture.home().join("arbitrary/CLAUDE.md"), + b"Home is not a project subtree", + ); + write(fixture.home().join(".claude/CLAUDE.md"), b"Known global"); + write( + fixture.project.path.join("nested/CLAUDE.md"), + b"Nested project", + ); + let globals = fixture.service.discover(None).unwrap(); + assert_eq!(globals.entries.len(), 1); + assert_eq!(globals.entries[0].scope, KnowledgeSourceScope::Global); + let project = fixture.service.discover(Some(&fixture.project)).unwrap(); + assert_eq!(project.entries.len(), 2); + assert!(project.issues.iter().any(|issue| { + issue.code == "project_subtree_scope" && issue.message.contains("ancestors") + })); + assert!( + project + .entries + .iter() + .any(|entry| entry.scope_directory == "nested") + ); +} diff --git a/crates/daemon/src/knowledge/discovery/tests.rs b/crates/daemon/src/knowledge/discovery/tests.rs index 74a14fd..e5cf82a 100644 --- a/crates/daemon/src/knowledge/discovery/tests.rs +++ b/crates/daemon/src/knowledge/discovery/tests.rs @@ -5,6 +5,9 @@ use tempfile::TempDir; use super::*; +#[path = "project_tests.rs"] +mod project_tests; + struct Fixture { root: TempDir, project: Project, @@ -89,7 +92,6 @@ fn discovers_exact_provider_locations_without_configs_or_imports() { ".env", ".claude/settings.json", ".codex/auth.json", - "nested/AGENTS.md", ] { write(fixture.project.path.join(path), b"PRIVATE_CONFIG_SENTINEL"); } @@ -105,7 +107,7 @@ fn discovers_exact_provider_locations_without_configs_or_imports() { assert!( scan.issues .iter() - .any(|issue| issue.code == "project_root_scope") + .any(|issue| issue.code == "project_subtree_scope") ); assert_eq!( fixture.service.scan_project_id(scan.scan_id).unwrap(), @@ -449,6 +451,7 @@ fn enumeration_limit_preserves_candidates_already_collected() { scanner.remaining_nodes = 2; scanner.scan_source(&SourceSpec { base: fixture.project.path.clone(), + scope_directory: ".".to_owned(), directory, provider, scope: KnowledgeSourceScope::Project, diff --git a/crates/daemon/src/knowledge/socket_tests.rs b/crates/daemon/src/knowledge/socket_tests.rs index a456d2c..d80b200 100644 --- a/crates/daemon/src/knowledge/socket_tests.rs +++ b/crates/daemon/src/knowledge/socket_tests.rs @@ -208,6 +208,100 @@ async fn discovery_and_explicit_read_use_scoped_capabilities_without_session_eff assert_scan_expired_after_restart(root.path(), &global).await; } +#[tokio::test] +async fn nested_sources_keep_their_scope_and_reject_replaced_parent_directories() { + let root = TempDir::new().unwrap(); + write(root.path(), "project/AGENTS.md", "Root instructions"); + write( + root.path(), + "project/packages/api service/AGENTS.md", + "Nested private instructions", + ); + write( + root.path(), + "project/packages/api service/.claude/skills/review/SKILL.md", + "Review this package only.", + ); + write( + root.path(), + "project/node_modules/dependency/AGENTS.md", + "Excluded dependency instructions", + ); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let project = success( + &mut client, + "project.add", + json!({"path":root.path().join("project")}), + ) + .await; + let scan = success( + &mut client, + "knowledge.discover", + json!({"projectId":project["id"]}), + ) + .await; + assert_eq!(scan["truncated"], false); + let serialized = serde_json::to_string(&scan).unwrap(); + assert!(!serialized.contains("Nested private instructions")); + assert!(!serialized.contains("node_modules/dependency")); + assert_eq!(source(&scan, "project/AGENTS.md")["scopeDirectory"], "."); + let nested = source(&scan, "packages/api service/AGENTS.md"); + assert_eq!(nested["scope"], "project"); + assert_eq!(nested["scopeDirectory"], "packages/api service"); + let skill = source(&scan, "api service/.claude/skills/review/SKILL.md"); + assert_eq!(skill["scopeDirectory"], "packages/api service"); + let read = success( + &mut client, + "knowledge.read", + json!({"scanId":scan["scanId"],"entryId":nested["entryId"]}), + ) + .await; + assert_eq!(read["entry"], nested); + assert_eq!(read["content"], "Nested private instructions"); + + // Keep the old directory alive so inode reuse cannot make replacement + // indistinguishable from the captured directory capability. + fs::rename( + root.path().join("project/packages/api service"), + root.path().join("original-package"), + ) + .unwrap(); + write( + root.path(), + "project/packages/api service/AGENTS.md", + "Replacement instructions", + ); + assert_eq!( + error_code( + exchange( + &mut client, + "knowledge.read", + json!({"scanId":scan["scanId"],"entryId":nested["entryId"]}), + ) + .await + ), + "knowledge_source_changed" + ); + let refreshed = success( + &mut client, + "knowledge.discover", + json!({"projectId":project["id"]}), + ) + .await; + let replacement = source(&refreshed, "packages/api service/AGENTS.md"); + let reread = success( + &mut client, + "knowledge.read", + json!({"scanId":refreshed["scanId"],"entryId":replacement["entryId"]}), + ) + .await; + assert_eq!(reread["content"], "Replacement instructions"); + assert_eq!(replacement["scopeDirectory"], "packages/api service"); + drop(client); + daemon.stop().await; +} + async fn assert_scan_expired_after_restart(root: &Path, global: &Value) { let daemon = Running::start(root); let mut client = daemon.connect().await; diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 685d628..5e0c1e7 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -13,9 +13,11 @@ mod config; mod diagnostics; mod error; mod events; +mod files; mod git_inspection; mod knowledge; mod lock; +mod organization; mod paths; mod preflight; mod projects; diff --git a/crates/daemon/src/organization/mod.rs b/crates/daemon/src/organization/mod.rs new file mode 100644 index 0000000..6794a69 --- /dev/null +++ b/crates/daemon/src/organization/mod.rs @@ -0,0 +1,58 @@ +//! Organization metadata has no session/process side effects. +use std::time::{SystemTime, UNIX_EPOCH}; + +use cli_master_core::{ + ApiError, + wire::{OrganizationGetRequest, OrganizationSaveRequest, method}, +}; +use cli_master_storage::Storage; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +pub(crate) fn dispatch(method: &str, payload: Value, storage: &Storage) -> Result { + match method { + method::ORGANIZATION_GET => { + let request: OrganizationGetRequest = decode(payload)?; + encode( + storage + .get_organization(&request) + .map_err(|error| error.to_api_error())?, + ) + } + method::ORGANIZATION_SAVE => { + let request: OrganizationSaveRequest = decode(payload)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .ok_or_else(|| { + ApiError::new("clock_unavailable", "The local clock is unavailable.") + })?; + encode( + storage + .save_organization(&request, now) + .map_err(|error| error.to_api_error())?, + ) + } + _ => Err(ApiError::new( + "unsupported_method", + "Unknown organization operation.", + )), + } +} + +fn decode(payload: Value) -> Result { + serde_json::from_value(payload).map_err(|_| { + ApiError::new("invalid_payload", "The organization request is invalid.") + .with_action("Check the selected entity, flags, workflow and revision.") + }) +} + +fn encode(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(|_| { + ApiError::new( + "internal_error", + "The organization response could not be encoded.", + ) + }) +} diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index ff8c65d..5381080 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -34,6 +34,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use uuid::Uuid; +use crate::files::LocalFileService; use crate::lock::InstanceLock; use crate::projects::ProjectRegistry; use crate::sessions::{SessionRegistry, encode_base64}; @@ -76,6 +77,7 @@ struct ServerState { diagnostics: DiagnosticsResponse, projects: ProjectRegistry, sessions: SessionRegistry, + files: LocalFileService, git_storage: Storage, knowledge_discovery: Mutex, git: Option, @@ -162,6 +164,7 @@ impl Daemon { crate::knowledge::discovery::DiscoveryRoots::from_environment(), )), git, + files: LocalFileService::default(), event_sequence: AtomicU64::new(0), }); @@ -561,6 +564,21 @@ async fn dispatch( } let result = match request.method.as_str() { + method::FILE_LIST | method::FILE_READ | method::FILE_WRITE => { + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || { + state + .files + .dispatch(&request.method, request.payload, &state.sessions) + }) + .await + .unwrap_or_else(|_| { + Err(ApiError::new( + "file_io_error", + "The local file operation could not complete.", + )) + }) + } method::KNOWLEDGE_LIST | method::KNOWLEDGE_SAVE | method::KNOWLEDGE_DELETE @@ -584,6 +602,19 @@ async fn dispatch( )), } } + method::ORGANIZATION_GET | method::ORGANIZATION_SAVE => { + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || { + crate::organization::dispatch(&request.method, request.payload, &state.git_storage) + }) + .await + .unwrap_or_else(|_| { + Err(ApiError::new( + "organization_operation_failed", + "The organization operation could not complete.", + )) + }) + } method::SYSTEM_HELLO => encode_response(&state.hello), method::STATE_SNAPSHOT => state.projects.snapshot().and_then(|projects| { let agents = state.sessions.agents()?; @@ -603,9 +634,25 @@ async fn dispatch( method::PROJECT_RENAME => decode_payload(request.payload) .and_then(|payload: ProjectRenameRequest| state.projects.rename(&payload)) .and_then(encode_response), - method::PROJECT_REMOVE => decode_payload(request.payload) - .and_then(|payload: ProjectRemoveRequest| state.projects.remove(payload)) - .and_then(encode_response), + method::PROJECT_REMOVE => match decode_payload::(request.payload) { + Ok(payload) => { + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || { + state + .sessions + .with_metadata_mutation(|| state.projects.remove(payload)) + .and_then(encode_response) + }) + .await + .unwrap_or_else(|_| { + Err(ApiError::new( + "project_operation_failed", + "The project metadata operation could not complete.", + )) + }) + } + Err(error) => Err(error), + }, method::AGENT_LIST => state.sessions.list_agents().and_then(encode_response), method::AGENT_DETECT => decode_payload(request.payload) .and_then(|payload: AgentDetectRequest| state.sessions.detect_agents(&payload)) diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 7813266..b86c7a0 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -24,6 +24,7 @@ use cli_master_session::{ use cli_master_storage::{SessionRuntimeUpdate, Storage, StorageError, StoredAgent, StoredSession}; use uuid::Uuid; +mod files; mod worktrees; const INITIAL_COLUMNS: u16 = 100; diff --git a/crates/daemon/src/sessions/files.rs b/crates/daemon/src/sessions/files.rs new file mode 100644 index 0000000..28da821 --- /dev/null +++ b/crates/daemon/src/sessions/files.rs @@ -0,0 +1,125 @@ +use std::path::Path; + +use cli_master_core::ApiError; +use cli_master_core::wire::FileTarget; +use cli_master_storage::{StoredWorktree, WorktreeState}; + +use crate::files::{FileTargetAccess, ResolvedFileTarget}; + +use super::{SessionRegistry, storage_error}; + +impl FileTargetAccess for SessionRegistry { + fn resolve_target( + &self, + target: &FileTarget, + write: bool, + ) -> Result { + let (root, worktrees) = { + let storage = self.storage()?; + let root = match *target { + FileTarget::Project { project_id } => storage + .get_project(project_id) + .map_err(storage_error)? + .map(|project| project.path), + FileTarget::Session { session_id } => storage + .get_session(session_id) + .map_err(storage_error)? + .map(|session| session.cwd), + FileTarget::Worktree { worktree_id } => storage + .get_worktree(worktree_id) + .map_err(storage_error)? + .map(|worktree| worktree.path), + } + .ok_or_else(|| { + ApiError::new( + "file_target_not_found", + "The selected file target is not registered.", + ) + .with_action("Refresh projects and sessions before opening files.") + })?; + (root, storage.list_worktrees().map_err(storage_error)?) + }; + if root.canonicalize().map_err(|_| target_changed())? != root || !root.is_dir() { + return Err(target_changed()); + } + // Projects may be registered directly at a managed checkout. Enforce the + // same identity and write policy even through such an alternate target ID. + for worktree in worktrees + .iter() + .filter(|worktree| root.starts_with(&worktree.path)) + { + self.validate_file_worktree(worktree, write)?; + } + Ok(ResolvedFileTarget { root }) + } + + fn with_write_lease( + &self, + target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result { + let _lifecycle = self.lifecycle()?; + if self.resolve_target(target, true)?.root != expected_root { + return Err(target_changed()); + } + operation() + } +} + +impl SessionRegistry { + fn validate_file_worktree( + &self, + worktree: &StoredWorktree, + write: bool, + ) -> Result<(), ApiError> { + if matches!( + worktree.state, + WorktreeState::Creating | WorktreeState::Orphaned + ) || (write && worktree.state != WorktreeState::Active) + || worktree.path.canonicalize().map_err(|_| target_changed())? != worktree.path + { + return Err(target_changed()); + } + let project = self + .storage()? + .get_project(worktree.project_id) + .map_err(storage_error)? + .ok_or_else(target_changed)?; + let git = self.git.as_ref().ok_or_else(target_changed)?; + let registered = git + .list_worktrees(&project.path) + .map_err(|_| target_changed())?; + let inspection = git + .inspect_repository(&worktree.path) + .map_err(|_| target_changed())?; + if inspection.repository_root.as_ref() != Some(&worktree.path) + || inspection.branch.as_deref() != Some(worktree.branch.as_str()) + || !registered.iter().any(|entry| { + entry.path == worktree.path + && entry.branch.as_deref() == Some(worktree.branch.as_str()) + && !entry.prunable + }) + { + return Err(target_changed()); + } + Ok(()) + } + + /// Metadata removal and the final file publish share the worktree mutation lease. + pub(crate) fn with_metadata_mutation( + &self, + operation: impl FnOnce() -> Result, + ) -> Result { + let _lifecycle = self.lifecycle()?; + operation() + } +} + +fn target_changed() -> ApiError { + ApiError::new( + "file_target_changed", + "The registered file target changed or needs recovery.", + ) + .with_action("Refresh the target and reopen the file before retrying.") +} diff --git a/crates/daemon/tests/file_ipc.rs b/crates/daemon/tests/file_ipc.rs new file mode 100644 index 0000000..18443b6 --- /dev/null +++ b/crates/daemon/tests/file_ipc.rs @@ -0,0 +1,1005 @@ +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use cli_master_core::{ApiError, RequestEnvelope, ResponseEnvelope, ResponsePayload}; +use cli_master_daemon::{Daemon, DaemonConfig, DaemonError, MAX_FRAME_LENGTH}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +const MAX_TEXT_BYTES: usize = 128 * 1024; +const MAX_LIST_BYTES: usize = 512 * 1024; +const INITIAL: &str = "\u{feff}first\r\nsecond\r\n"; + +type Client = Framed; + +struct RunningDaemon { + config: DaemonConfig, + cancellation: CancellationToken, + task: JoinHandle>, +} + +impl RunningDaemon { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).expect("daemon should bind"); + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(task_cancellation).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed( + UnixStream::connect(self.config.socket_path()) + .await + .unwrap(), + ) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(10), self.task) + .await + .expect("daemon should stop before timeout") + .expect("daemon task should join") + .expect("daemon should stop cleanly"); + } +} + +struct Fixture { + root: TempDir, + repository: PathBuf, + selected: PathBuf, + target: Value, + project_id: Value, + agent_id: Value, +} + +impl Fixture { + async fn new() -> (Self, RunningDaemon, Client) { + let root = TempDir::new().unwrap(); + let repository = root.path().join("repository"); + let selected = repository.join("apps"); + fs::create_dir_all(selected.join("api")).unwrap(); + fs::write(selected.join("api/notes.txt"), INITIAL).unwrap(); + fs::write(repository.join("outside.txt"), "outside selected project\n").unwrap(); + git(&repository, &["init", "-b", "main"]); + git( + &repository, + &["config", "user.email", "tests@example.invalid"], + ); + git(&repository, &["config", "user.name", "CLI Master Tests"]); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "initial"]); + let daemon = RunningDaemon::start(root.path()); + let mut client = daemon.connect().await; + let project = call(&mut client, "project.add", json!({"path": selected})).await; + let agent = call( + &mut client, + "agent.custom.create", + json!({ + "displayName": "File fixture", + "command": {"executable": "/bin/cat", "args": [], "env": {}} + }), + ) + .await; + ( + Self { + root, + repository, + selected, + target: json!({"kind": "project", "projectId": project["id"]}), + project_id: project["id"].clone(), + agent_id: agent["id"].clone(), + }, + daemon, + client, + ) + } + + async fn session(&self, client: &mut Client, isolation: &str) -> Value { + call( + client, + "session.create", + json!({ + "projectId": self.project_id, + "agentId": self.agent_id, + "name": "File session", + "isolation": isolation, + "relativeDirectory": "api" + }), + ) + .await + } +} + +fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn path(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +fn request(target: &Value, bytes: &[u8]) -> Value { + json!({"target": target, "pathBase64": path(bytes)}) +} + +fn write_request(target: &Value, bytes: &[u8], text: &str, revision: &str) -> Value { + json!({"target": target, "pathBase64": path(bytes), "text": text, "expectedRevision": revision}) +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + let encoded = serde_json::to_vec(&request).unwrap(); + assert!( + encoded.len() <= MAX_FRAME_LENGTH, + "test request must fit the wire frame" + ); + client.send(encoded.into()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame = client + .next() + .await + .expect("response should arrive") + .unwrap(); + assert!(frame.len() <= MAX_FRAME_LENGTH); + let envelope: Value = serde_json::from_slice(&frame).unwrap(); + if envelope["kind"] == "event" { + continue; + } + let response: ResponseEnvelope = serde_json::from_value(envelope).unwrap(); + assert_eq!(response.request_id, request.request_id); + return response; + } + }) + .await + .unwrap_or_else(|_| panic!("{method} must not hang")) +} + +async fn call(client: &mut Client, method: &str, payload: Value) -> Value { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => data, + ResponsePayload::Error { error } => panic!("{method} failed: {error:?}"), + } +} + +async fn failure(client: &mut Client, method: &str, payload: Value) -> ApiError { + match exchange(client, method, payload).await.payload { + ResponsePayload::Error { error } => error, + ResponsePayload::Success { data } => panic!("{method} unexpectedly succeeded: {data}"), + } +} + +async fn read(client: &mut Client, target: &Value, bytes: &[u8]) -> Value { + call(client, "file.read", request(target, bytes)).await +} + +fn revision(response: &Value) -> &str { + let revision = response["revision"] + .as_str() + .expect("opaque revision should be present"); + assert_eq!(revision.len(), 67); + assert!(revision.starts_with("v1:")); + assert!( + revision.as_bytes()[3..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + ); + revision +} + +fn entry_paths(response: &Value) -> Vec> { + response["entries"] + .as_array() + .expect("directory entries") + .iter() + .map(|entry| { + STANDARD + .decode(entry["pathBase64"].as_str().unwrap()) + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn registered_project_session_and_worktree_roots_list_read_and_save_real_bytes() { + let (fixture, daemon, mut client) = Fixture::new().await; + let session = fixture.session(&mut client, "current").await; + let isolated = fixture.session(&mut client, "new_worktree").await; + let session_target = json!({"kind": "session", "sessionId": session["id"]}); + let isolated_target = json!({"kind": "worktree", "worktreeId": isolated["worktreeId"]}); + let isolated_root = PathBuf::from(isolated["worktreePath"].as_str().unwrap()); + let cases = [ + ( + fixture.target.clone(), + b"api/notes.txt".as_slice(), + fixture.selected.join("api/notes.txt"), + ), + ( + session_target, + b"notes.txt".as_slice(), + fixture.selected.join("api/notes.txt"), + ), + ( + isolated_target, + b"apps/api/notes.txt".as_slice(), + isolated_root.join("apps/api/notes.txt"), + ), + ]; + for (index, (target, name, disk)) in cases.iter().enumerate() { + let listed = call(&mut client, "file.list", request(target, b"")).await; + assert!(!entry_paths(&listed).is_empty()); + assert!(listed["observedAtMs"].as_i64().unwrap() > 1_700_000_000_000); + let before = read(&mut client, target, name).await; + assert_eq!(before["text"], fs::read_to_string(disk).unwrap()); + let text = format!("\u{feff}olá {index} 🦀\r\nkeep CRLF\r\n"); + let saved = call( + &mut client, + "file.write", + write_request(target, name, &text, revision(&before)), + ) + .await; + assert_eq!(saved["pathBase64"], path(name)); + assert_eq!(saved["sizeBytes"], text.len()); + assert_ne!(revision(&before), revision(&saved)); + assert_eq!(fs::read(disk).unwrap(), text.as_bytes()); + let after = read(&mut client, target, name).await; + assert_eq!(after["text"], text); + assert_eq!(revision(&after), revision(&saved)); + } + let listed = call(&mut client, "file.list", request(&fixture.target, b"")).await; + assert_eq!(entry_paths(&listed), [b"api".to_vec()]); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, b"outside.txt") + ) + .await + .code, + "file_not_found" + ); + assert_eq!( + fs::read_to_string(fixture.repository.join("outside.txt")).unwrap(), + "outside selected project\n" + ); + daemon.stop().await; +} + +#[tokio::test] +async fn unix_filename_bytes_round_trip_through_listing_and_followup_identifiers() { + let (fixture, daemon, mut client) = Fixture::new().await; + let mut names: Vec> = vec![ + b"space name.txt".to_vec(), + b"-leading.txt".to_vec(), + b"literal\\name:part".to_vec(), + "ação.txt".as_bytes().to_vec(), + b"line\n\x01.txt".to_vec(), + ]; + let non_utf8_name = b"bad-\xff.txt".to_vec(); + match fs::write( + fixture + .selected + .join(OsString::from_vec(non_utf8_name.clone())), + "before", + ) { + Ok(()) => names.push(non_utf8_name), + // APFS rejects invalid UTF-8 before it reaches the file service. Linux + // must exercise this case; the pure wire contract covers it on both OSes. + Err(error) + if cfg!(target_os = "macos") + && error.raw_os_error() == Some(rustix::io::Errno::ILSEQ.raw_os_error()) => {} + Err(error) => panic!("non-UTF-8 fixture failed unexpectedly: {error}"), + } + for name in &names { + fs::write( + fixture.selected.join(OsString::from_vec(name.clone())), + "before", + ) + .unwrap(); + } + let listing = call(&mut client, "file.list", request(&fixture.target, b"")).await; + let paths = entry_paths(&listing); + let mut sorted = paths.clone(); + sorted.sort(); + assert_eq!(paths, sorted, "sorting must follow raw Unix bytes"); + for name in &names { + let entry = listing["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + STANDARD + .decode(entry["pathBase64"].as_str().unwrap()) + .unwrap() + == *name + }) + .expect("every byte-exact filename should be listable"); + assert!( + !entry["displayName"] + .as_str() + .unwrap() + .chars() + .any(char::is_control) + ); + let payload = json!({"target": fixture.target, "pathBase64": entry["pathBase64"]}); + let before = call(&mut client, "file.read", payload.clone()).await; + let mut write = payload; + write["text"] = json!("after 🦀\r\n"); + write["expectedRevision"] = before["revision"].clone(); + call(&mut client, "file.write", write).await; + assert_eq!( + fs::read(fixture.selected.join(OsString::from_vec(name.clone()))).unwrap(), + "after 🦀\r\n".as_bytes() + ); + } + daemon.stop().await; +} + +#[tokio::test] +async fn invalid_paths_revision_and_unregistered_targets_are_rejected_at_the_wire_boundary() { + let (fixture, daemon, mut client) = Fixture::new().await; + let invalid: &[&[u8]] = &[ + b"", + b"/absolute.txt", + b"..", + b"api/../notes.txt", + b"api//notes.txt", + b"api/./notes.txt", + b"api/", + b"api\0notes.txt", + ]; + for bytes in invalid { + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, bytes)) + .await + .code, + "invalid_input", + "{bytes:?}" + ); + } + for encoded in ["%%%", "YQ", "YR=="] { + assert_eq!( + failure( + &mut client, + "file.read", + json!({"target": fixture.target, "pathBase64": encoded}) + ) + .await + .code, + "invalid_input" + ); + } + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, &vec![b'a'; 4097]) + ) + .await + .code, + "invalid_input" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + "not-a-revision" + ) + ) + .await + .code, + "invalid_input" + ); + let missing_target = json!({"kind": "project", "projectId": cli_master_core::ProjectId::new()}); + assert_eq!( + failure( + &mut client, + "file.read", + request(&missing_target, b"notes.txt") + ) + .await + .code, + "file_target_not_found" + ); + let mut extra_path = request(&fixture.target, b"api/notes.txt"); + extra_path["path"] = json!(fixture.repository.join("outside.txt")); + assert_eq!( + failure(&mut client, "file.read", extra_path).await.code, + "invalid_input" + ); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + INITIAL + ); + daemon.stop().await; +} + +#[tokio::test] +async fn symlinks_directories_and_fifo_leaves_are_never_opened_as_regular_text() { + let (fixture, daemon, mut client) = Fixture::new().await; + symlink( + fixture.selected.join("api/notes.txt"), + fixture.selected.join("link.txt"), + ) + .unwrap(); + symlink( + fixture.selected.join("api"), + fixture.selected.join("linked-dir"), + ) + .unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + for bytes in [b"link.txt".as_slice(), b"linked-dir/notes.txt".as_slice()] { + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, bytes)) + .await + .code, + "file_symlink_not_allowed" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&fixture.target, bytes, "changed", revision(&before)) + ) + .await + .code, + "file_symlink_not_allowed" + ); + } + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, b"api")) + .await + .code, + "file_not_regular" + ); + assert_eq!( + failure( + &mut client, + "file.list", + request(&fixture.target, b"api/notes.txt") + ) + .await + .code, + "file_not_directory" + ); + + let fifo = fixture.selected.join("api/notes.txt"); + fs::remove_file(&fifo).unwrap(); + let output = Command::new("mkfifo").arg(&fifo).output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, b"api/notes.txt") + ) + .await + .code, + "file_not_regular" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&before) + ) + ) + .await + .code, + "file_not_regular" + ); + let listing = call(&mut client, "file.list", request(&fixture.target, b"api")).await; + assert_eq!(listing["entries"][0]["kind"], "other"); + let root_listing = call(&mut client, "file.list", request(&fixture.target, b"")).await; + assert_eq!( + root_listing["entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["kind"] == "symlink") + .count(), + 2 + ); + daemon.stop().await; +} + +#[tokio::test] +async fn a_replaced_registered_root_and_pending_worktree_removal_reject_writes() { + let (fixture, daemon, mut client) = Fixture::new().await; + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let moved = fixture.root.path().join("moved-apps"); + fs::rename(&fixture.selected, &moved).unwrap(); + symlink(&moved, &fixture.selected).unwrap(); + let read_error = failure( + &mut client, + "file.read", + request(&fixture.target, b"api/notes.txt"), + ) + .await; + let write_error = failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&before), + ), + ) + .await; + fs::remove_file(&fixture.selected).unwrap(); + fs::rename(&moved, &fixture.selected).unwrap(); + assert_eq!(read_error.code, "file_target_changed"); + assert_eq!(write_error.code, "file_target_changed"); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + INITIAL + ); + + let isolated = fixture.session(&mut client, "new_worktree").await; + let target = json!({"kind": "worktree", "worktreeId": isolated["worktreeId"]}); + let before = read(&mut client, &target, b"apps/api/notes.txt").await; + let prepared = call( + &mut client, + "worktree.prepare_remove", + json!({"worktreeId": isolated["worktreeId"]}), + ) + .await; + assert_eq!(prepared["status"], "ready"); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&target, b"apps/api/notes.txt", "changed", revision(&before)) + ) + .await + .code, + "file_target_changed" + ); + assert_eq!( + fs::read_to_string( + Path::new(isolated["worktreePath"].as_str().unwrap()).join("apps/api/notes.txt") + ) + .unwrap(), + INITIAL + ); + daemon.stop().await; +} + +#[tokio::test] +async fn binary_oversize_and_unsupported_hardlinks_are_rejected_without_data_loss() { + let (fixture, daemon, mut client) = Fixture::new().await; + for (name, bytes, code) in [ + ("nul.bin", vec![b'a', 0, b'b'], "file_not_text"), + ("invalid.bin", vec![0xff, 0xfe], "file_not_text"), + ( + "large.txt", + vec![b'x'; MAX_TEXT_BYTES + 1], + "file_too_large", + ), + ] { + fs::write(fixture.selected.join(name), &bytes).unwrap(); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, name.as_bytes()) + ) + .await + .code, + code + ); + assert_eq!(fs::read(fixture.selected.join(name)).unwrap(), bytes); + } + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let too_large = "é".repeat(MAX_TEXT_BYTES / 2 + 1); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + &too_large, + revision(&before) + ) + ) + .await + .code, + "invalid_input" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&fixture.target, b"missing.txt", "new", revision(&before)) + ) + .await + .code, + "file_not_found" + ); + assert!(!fixture.selected.join("missing.txt").exists()); + + let original = fixture.selected.join("api/notes.txt"); + let linked = fixture.root.path().join("hardlink.txt"); + fs::hard_link(&original, &linked).unwrap(); + let linked_read = read(&mut client, &fixture.target, b"api/notes.txt").await; + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&linked_read) + ) + ) + .await + .code, + "file_metadata_unsupported" + ); + assert_eq!(fs::read_to_string(&original).unwrap(), INITIAL); + assert_eq!(fs::read_to_string(&linked).unwrap(), INITIAL); + assert_eq!( + fs::metadata(original).unwrap().ino(), + fs::metadata(linked).unwrap().ino() + ); + daemon.stop().await; +} + +#[tokio::test] +async fn two_clients_cannot_both_save_one_revision_even_through_different_registered_targets() { + let (fixture, daemon, mut project_client) = Fixture::new().await; + let session = fixture.session(&mut project_client, "current").await; + let session_target = json!({"kind": "session", "sessionId": session["id"]}); + let mut session_client = daemon.connect().await; + let project_read = read(&mut project_client, &fixture.target, b"api/notes.txt").await; + let session_read = read(&mut session_client, &session_target, b"notes.txt").await; + assert_eq!(revision(&project_read), revision(&session_read)); + let writes = ["project wins\n", "session wins\n"]; + let (first, second) = tokio::join!( + exchange( + &mut project_client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + writes[0], + revision(&project_read) + ) + ), + exchange( + &mut session_client, + "file.write", + write_request( + &session_target, + b"notes.txt", + writes[1], + revision(&session_read) + ) + ), + ); + let mut winner = None; + let mut conflicts = 0; + for (response, candidate) in [first, second].into_iter().zip(writes) { + match response.payload { + ResponsePayload::Success { data } => { + assert!( + winner.replace(candidate).is_none(), + "one revision must have exactly one successful writer" + ); + revision(&data); + } + ResponsePayload::Error { error } => { + assert_eq!(error.code, "file_conflict"); + assert!(!format!("{error:?}").contains(INITIAL)); + conflicts += 1; + } + } + } + assert_eq!(conflicts, 1); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + winner.unwrap() + ); + daemon.stop().await; +} + +#[tokio::test] +async fn external_content_and_inode_changes_conflict_and_saves_preserve_unix_permissions() { + let (fixture, daemon, mut client) = Fixture::new().await; + let disk = fixture.selected.join("api/notes.txt"); + fs::set_permissions(&disk, fs::Permissions::from_mode(0o640)).unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + fs::write(&disk, "external content\n").unwrap(); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "stale", + revision(&before) + ) + ) + .await + .code, + "file_conflict" + ); + assert_eq!(fs::read_to_string(&disk).unwrap(), "external content\n"); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let replacement = fixture.selected.join("api/replacement.txt"); + fs::write(&replacement, "external content\n").unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o640)).unwrap(); + fs::rename(&replacement, &disk).unwrap(); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "stale", + revision(&before) + ) + ) + .await + .code, + "file_conflict" + ); + + let metadata = fs::metadata(&disk).unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "current\r\n", + revision(&before), + ), + ) + .await; + let saved = fs::metadata(&disk).unwrap(); + assert_eq!(saved.permissions().mode() & 0o777, 0o640); + assert_eq!(saved.uid(), metadata.uid()); + assert_eq!(saved.gid(), metadata.gid()); + assert_eq!(fs::read(&disk).unwrap(), b"current\r\n"); + daemon.stop().await; +} + +#[tokio::test] +async fn saved_text_and_revision_survive_daemon_restart_and_remain_writable() { + let (fixture, first, mut client) = Fixture::new().await; + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let saved = call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "persisted\r\n", + revision(&before), + ), + ) + .await; + drop(client); + first.stop().await; + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let recovered = read(&mut client, &fixture.target, b"api/notes.txt").await; + assert_eq!(recovered["text"], "persisted\r\n"); + assert_eq!(revision(&recovered), revision(&saved)); + call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "saved after restart\n", + revision(&saved), + ), + ) + .await; + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + "saved after restart\n" + ); + second.stop().await; +} + +#[tokio::test] +async fn pagination_is_byte_ordered_and_rejects_unbounded_enumerations() { + let (fixture, daemon, mut client) = Fixture::new().await; + let pages = fixture.selected.join("pages"); + fs::create_dir(&pages).unwrap(); + for index in 0..205 { + fs::write(pages.join(format!("{index:03}.txt")), []).unwrap(); + } + let first = call(&mut client, "file.list", request(&fixture.target, b"pages")).await; + assert_eq!(first["entries"].as_array().unwrap().len(), 100); + assert_eq!(first["nextAfterNameBase64"], path(b"099.txt")); + let second = call( + &mut client, + "file.list", + json!({ + "target": fixture.target, "pathBase64": path(b"pages"), "limit": 200, + "afterNameBase64": first["nextAfterNameBase64"] + }), + ) + .await; + assert_eq!(second["entries"].as_array().unwrap().len(), 105); + assert!(second.get("nextAfterNameBase64").is_none()); + let combined = [entry_paths(&first), entry_paths(&second)].concat(); + assert_eq!( + combined, + (0..205) + .map(|index| format!("pages/{index:03}.txt").into_bytes()) + .collect::>() + ); + for limit in [0, 201] { + assert_eq!( + failure( + &mut client, + "file.list", + json!({"target": fixture.target, "pathBase64": path(b"pages"), "limit": limit}) + ) + .await + .code, + "invalid_input" + ); + } + let large = fixture.selected.join("many"); + fs::create_dir(&large).unwrap(); + for index in 0..10_001 { + fs::write(large.join(format!("{index:05}")), []).unwrap(); + } + assert_eq!( + failure(&mut client, "file.list", request(&fixture.target, b"many")) + .await + .code, + "file_listing_too_large" + ); + daemon.stop().await; +} + +#[tokio::test] +async fn maximum_text_with_worst_case_json_escaping_round_trips_inside_one_frame() { + let (fixture, daemon, mut client) = Fixture::new().await; + let text = "\u{0001}".repeat(MAX_TEXT_BYTES); + fs::write(fixture.selected.join("escaped.txt"), &text).unwrap(); + let before = read(&mut client, &fixture.target, b"escaped.txt").await; + assert_eq!(before["text"], text); + assert_eq!(before["sizeBytes"], MAX_TEXT_BYTES); + assert!(serde_json::to_vec(&before).unwrap().len() > 6 * MAX_TEXT_BYTES); + let updated = "\u{0002}".repeat(MAX_TEXT_BYTES); + let saved = call( + &mut client, + "file.write", + write_request(&fixture.target, b"escaped.txt", &updated, revision(&before)), + ) + .await; + assert_eq!( + fs::read(fixture.selected.join("escaped.txt")).unwrap(), + updated.as_bytes() + ); + assert_eq!( + revision(&read(&mut client, &fixture.target, b"escaped.txt").await), + revision(&saved) + ); + daemon.stop().await; +} + +#[tokio::test] +async fn directory_pages_respect_the_encoded_response_budget_without_losing_names() { + let (fixture, daemon, mut client) = Fixture::new().await; + let mut directory = fs::File::open(&fixture.selected).unwrap(); + let mut relative = Vec::new(); + for index in 0..20 { + let component = format!("d{index:02}{}", "a".repeat(117)); + rustix::fs::mkdirat(&directory, &component, rustix::fs::Mode::RWXU).unwrap(); + directory = rustix::fs::openat( + &directory, + &component, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) + .unwrap() + .into(); + if !relative.is_empty() { + relative.push(b'/'); + } + relative.extend_from_slice(component.as_bytes()); + } + for index in 0..200 { + let name = format!("n{index:03}{}", "b".repeat(216)); + rustix::fs::openat( + &directory, + &name, + rustix::fs::OFlags::WRONLY | rustix::fs::OFlags::CREATE | rustix::fs::OFlags::EXCL, + rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, + ) + .unwrap(); + } + let mut cursor: Option = None; + let mut all_paths = Vec::new(); + loop { + let mut payload = + json!({"target": fixture.target, "pathBase64": path(&relative), "limit": 200}); + if let Some(after) = &cursor { + payload["afterNameBase64"] = after.clone(); + } + let page = call(&mut client, "file.list", payload).await; + assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_LIST_BYTES); + let names = entry_paths(&page); + assert!(!names.is_empty()); + if cursor.is_none() { + assert!( + names.len() < 200, + "encoded budget must paginate before entry limit" + ); + } + all_paths.extend(names); + assert!( + all_paths.len() <= 200, + "pagination must not repeat previous names" + ); + cursor = page.get("nextAfterNameBase64").cloned(); + if cursor.is_none() { + break; + } + } + assert_eq!(all_paths.len(), 200); + let mut unique = all_paths.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(all_paths, unique); + daemon.stop().await; +} diff --git a/crates/daemon/tests/organization_ipc.rs b/crates/daemon/tests/organization_ipc.rs new file mode 100644 index 0000000..e41363d --- /dev/null +++ b/crates/daemon/tests/organization_ipc.rs @@ -0,0 +1,253 @@ +//! Real socket/SQLite acceptance for organization without process side effects. +use std::{path::Path, process::Command, time::Duration}; + +use cli_master_core::{RequestEnvelope, ResponseEnvelope, ResponsePayload}; +use cli_master_daemon::{Daemon, DaemonConfig, MAX_FRAME_LENGTH}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio_util::{ + codec::{Framed, LengthDelimitedCodec}, + sync::CancellationToken, +}; + +type Client = Framed; + +struct Running { + config: DaemonConfig, + cancellation: CancellationToken, + task: tokio::task::JoinHandle>, +} + +impl Running { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).unwrap(); + let cancellation = CancellationToken::new(); + let token = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(token).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed( + UnixStream::connect(self.config.socket_path()) + .await + .unwrap(), + ) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .unwrap(); + let bytes = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(bytes.len() <= MAX_FRAME_LENGTH); + let response: ResponseEnvelope = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(response.request_id, request.request_id); + response +} + +async fn success(client: &mut Client, method: &str, payload: Value) -> Value { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => data, + ResponsePayload::Error { error } => panic!("{method}: {}", error.code), + } +} + +fn error_code(response: ResponseEnvelope) -> String { + match response.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { .. } => panic!("expected request failure"), + } +} + +async fn project(client: &mut Client, root: &Path, name: &str) -> Value { + let path = root.join(name); + std::fs::create_dir(&path).unwrap(); + assert!( + Command::new("git") + .args(["init", "--initial-branch=main"]) + .current_dir(&path) + .output() + .unwrap() + .status + .success() + ); + success(client, "project.add", json!({"path":path, "name":name})).await["id"].clone() +} + +async fn started_session(client: &mut Client, project_id: &Value) -> Value { + let agent = success(client, "agent.custom.create", json!({ + "displayName":"Organization test child", "command":{"executable":"/bin/cat","args":[],"env":{}} + })).await; + let session = success(client, "session.create", json!({ + "projectId":project_id,"agentId":agent["id"],"name":"Visible process","isolation":"current" + })).await; + success(client, "session.start", json!({"sessionId":session["id"]})).await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let list = success(client, "session.list", json!({"projectId":project_id})).await; + let current = &list["sessions"][0]; + if current["status"] == "running" { + return current.clone(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap() +} + +#[tokio::test] +async fn archive_and_workflow_preserve_the_live_process_and_survive_restart() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let project_id = project(&mut client, root.path(), "organized").await; + let session = started_session(&mut client, &project_id).await; + let session_target = json!({"kind":"session","id":session["id"]}); + let project_target = json!({"kind":"project","id":project_id}); + let targets = json!([session_target, project_target]); + let defaults = success(&mut client, "organization.get", json!({"targets":targets})).await; + assert_eq!(defaults["entries"][0]["workflow"], "backlog"); + assert_eq!(defaults["entries"][0]["target"], session_target); + assert_eq!(defaults["entries"][1]["target"], project_target); + assert_eq!(defaults["entries"][1]["revision"], 0); + assert!(defaults["entries"][1]["updatedAtMs"].is_null()); + let pinned = success(&mut client, "organization.save", json!({ + "target":project_target,"expectedRevision":0,"pinned":true,"archived":true,"workflow":null + })).await; + let mut saved = Value::Null; + for (revision, workflow) in ["in_progress", "in_review", "blocked", "done", "backlog"] + .iter() + .enumerate() + { + saved = success(&mut client, "organization.save", json!({ + "target":session_target,"expectedRevision":revision,"pinned":true,"archived":true,"workflow":workflow + })).await; + assert_eq!(saved["revision"], revision + 1); + let live = success(&mut client, "session.list", json!({"projectId":project_id})).await; + assert_eq!(live["sessions"][0], session); + } + let mut second = daemon.connect().await; + assert_eq!(error_code(exchange(&mut second, "organization.save", json!({ + "target":session_target,"expectedRevision":0,"pinned":false,"archived":false,"workflow":"done" + })).await), "organization_conflict"); + let current = success(&mut second, "organization.get", json!({"targets":targets})).await; + assert_eq!(current["entries"], json!([saved, pinned])); + success( + &mut client, + "session.stop", + json!({"sessionId":session["id"]}), + ) + .await; + drop(client); + drop(second); + daemon.stop().await; + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + assert_eq!( + success(&mut client, "organization.get", json!({"targets":targets})).await, + current + ); + success( + &mut client, + "session.delete", + json!({"sessionId":session["id"]}), + ) + .await; + assert_eq!( + error_code( + exchange( + &mut client, + "organization.get", + json!({"targets":[session_target]}) + ) + .await + ), + "session_not_found" + ); + success( + &mut client, + "project.remove", + json!({"projectId":project_id}), + ) + .await; + assert_eq!( + error_code( + exchange( + &mut client, + "organization.get", + json!({"targets":[project_target]}) + ) + .await + ), + "project_not_found" + ); + assert!(root.path().join("organized/.git").is_dir()); + daemon.stop().await; +} + +#[tokio::test] +async fn organization_rejects_process_fields_and_invalid_batches_without_echoing_values() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let id = project(&mut client, root.path(), "validation").await; + let target = json!({"kind":"project","id":id}); + for targets in [ + json!([]), + json!([target, target]), + json!([{"kind":"path","id":"private-canary"}]), + ] { + let response = exchange(&mut client, "organization.get", json!({"targets":targets})).await; + assert!( + !serde_json::to_string(&response) + .unwrap() + .contains("private-canary") + ); + assert_eq!(error_code(response), "invalid_payload"); + } + for payload in [ + json!({"target":target,"expectedRevision":0,"pinned":false,"archived":true,"workflow":"private-canary"}), + json!({"target":target,"expectedRevision":0,"pinned":false,"archived":true,"workflow":null,"status":"private-canary"}), + json!({"target":target,"expectedRevision":0,"pinned":false,"archived":true}), + json!({"target":target,"expectedRevision":-1,"pinned":false,"archived":true,"workflow":null}), + ] { + let response = exchange(&mut client, "organization.save", payload).await; + assert!( + !serde_json::to_string(&response) + .unwrap() + .contains("private-canary") + ); + assert_eq!(error_code(response), "invalid_payload"); + } + let empty = success(&mut client, "organization.get", json!({"targets":[target]})).await; + assert_eq!(empty["entries"][0]["revision"], 0); + assert_eq!(empty["entries"][0]["archived"], false); + daemon.stop().await; +} diff --git a/crates/file-metadata/Cargo.toml b/crates/file-metadata/Cargo.toml new file mode 100644 index 0000000..93fcc57 --- /dev/null +++ b/crates/file-metadata/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cli-master-file-metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dev-dependencies] +tempfile = "3" + +# ADR 0006: only the private Darwin module may override this denial. The +# workspace-wide forbid policy remains in force for every other crate. +[lints.rust] +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/crates/file-metadata/SAFETY.md b/crates/file-metadata/SAFETY.md new file mode 100644 index 0000000..9f927bd --- /dev/null +++ b/crates/file-metadata/SAFETY.md @@ -0,0 +1,50 @@ +# Darwin ACL boundary + +ADR [0006](../../docs/adr/0006-local-files-and-editor.md) accepts this narrow +exception because the editor must inspect metadata on its already-open file. +All public APIs are safe Rust. Only the private `darwin` module permits unsafe +code; the workspace and other crates retain `unsafe_code = "forbid"`. + +The three bindings match `sys/acl.h` in Apple's installed macOS SDK: +`acl_get_fd_np(int, acl_type_t) -> acl_t`, +`acl_get_entry(acl_t, int, acl_entry_t *) -> int`, and +`acl_free(void *) -> int`. Opaque object and entry pointers are never +dereferenced by Rust. The positive `acl_type_t` enum uses C unsigned-int ABI; +`ACL_TYPE_EXTENDED` is `0x100`, and `ACL_FIRST_ENTRY` is `0`. +`sys/errno.h` defines `ENOENT = 2` and `EINVAL = 22` on Darwin. + +- A borrowed `AsFd` descriptor remains valid for the entire inspection. The + helper neither closes it nor constructs a pathname from it. +- A successful `acl_get_fd_np` allocates an independent ACL. `OwnedAcl` owns + and frees it exactly once, including error returns. Its pointer is private; + it has no clone operation or manual `Send`/`Sync` implementation. +- `acl_get_entry` receives a live ACL and writable pointer-sized output. The + borrowed entry is never read or exported. Darwin returns **0 for success**, + and **-1/EINVAL for no first entry** in a valid allocated ACL. + Both mean an ACL is present: even an allocated empty ACL may carry ACL-level + inheritance flags, so the helper conservatively preserves that policy. +- No-ACL files can instead return NULL/ENOENT from `acl_get_fd_np` because + `FILESEC_ACL` is absent. Only this documented absence maps to `false` at + acquisition; other OS errors propagate. Errno is captured immediately, + before the RAII destructor can call `acl_free`. +- This is an observation, not synchronization with other writers. Atomic save + still requires the daemon's metadata and revision rechecks. This helper + does not copy metadata or assert that a later replacement is safe. + +These semantics follow Apple's primary +[get-ACL documentation](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/acl_get.3.html), +[entry documentation](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/acl_get_entry.3.html), +[ACL file implementation](https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c), +[entry implementation](https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_entry.c), and +[security-property implementation](https://github.com/apple-oss-distributions/Libc/blob/main/gen/filesec.c). +The constants and signatures were also checked against +`/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/acl.h` +and `sys/errno.h` on the development host. + +Integration tests use the safe public API on real temporary files, covering +no ACL, explicit ACL, inheritance, ACL removal, and renamed/replaced paths. +An ACL that denies reading security metadata verifies error propagation. +Only test fixture setup invokes `/bin/chmod`, directly with an argv array; +the library has no subprocess or filesystem-path dependency. On Linux the +helper returns `Unsupported`; the daemon's safe rustix xattr checks own Linux +ACL detection. diff --git a/crates/file-metadata/src/darwin.rs b/crates/file-metadata/src/darwin.rs new file mode 100644 index 0000000..4e11d52 --- /dev/null +++ b/crates/file-metadata/src/darwin.rs @@ -0,0 +1,68 @@ +//! Minimal bindings verified against Apple's SDK `sys/acl.h` and `sys/errno.h`. +//! See `../SAFETY.md` for the ownership and return-value audit. + +use std::ffi::{c_int, c_uint, c_void}; +use std::io; +use std::os::fd::{AsRawFd, BorrowedFd}; +use std::ptr::{self, NonNull}; + +// acl_type_t is a C enum whose defined values are nonnegative; its Darwin ABI +// representation is unsigned int. acl_t and acl_entry_t are opaque pointers. +const ACL_TYPE_EXTENDED: c_uint = 0x0000_0100; +const ACL_FIRST_ENTRY: c_int = 0; +const ENOENT: c_int = 2; +const EINVAL: c_int = 22; + +unsafe extern "C" { + fn acl_get_fd_np(fd: c_int, acl_type: c_uint) -> *mut c_void; + fn acl_get_entry(acl: *mut c_void, entry_id: c_int, entry: *mut *mut c_void) -> c_int; + fn acl_free(object: *mut c_void) -> c_int; +} + +/// Owns exactly one allocation returned by `acl_get_fd_np`. +struct OwnedAcl(NonNull); + +impl Drop for OwnedAcl { + fn drop(&mut self) { + // SAFETY: this non-null pointer came from acl_get_fd_np and is freed + // exactly once. No ACL entry pointer escapes or survives this owner. + let _ = unsafe { acl_free(self.0.as_ptr()) }; + } +} + +pub(super) fn has_extended_acl(fd: BorrowedFd<'_>) -> io::Result { + // SAFETY: BorrowedFd keeps the descriptor valid for this call. The supported + // ACL type is fixed, and the returned ACL is a separately owned allocation. + let acl = unsafe { acl_get_fd_np(fd.as_raw_fd(), ACL_TYPE_EXTENDED) }; + let Some(acl) = NonNull::new(acl) else { + let error = io::Error::last_os_error(); + // Darwin's filesec_get_property(FILESEC_ACL) reports ENOENT when the + // descriptor's security metadata has no ACL property. No path is used. + return if error.raw_os_error() == Some(ENOENT) { + Ok(false) + } else { + Err(error) + }; + }; + let acl = OwnedAcl(acl); + let mut entry = ptr::null_mut(); + // SAFETY: the owned ACL remains live, ACL_FIRST_ENTRY is a valid selector, + // and entry is initialized writable storage for the borrowed output pointer. + let result = unsafe { acl_get_entry(acl.0.as_ptr(), ACL_FIRST_ENTRY, &raw mut entry) }; + if result == 0 { + return Ok(true); + } + + // Capture errno before OwnedAcl::drop calls another C function. Darwin + // returns -1/EINVAL when the first entry of a valid ACL does not exist. + // Unlike Linux's ACL API, Darwin does not return zero for end-of-list. + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(EINVAL) { + // An existing but empty ACL can still have ACL-level inheritance flags. + // Preserve that distinction from a missing ACL property: the editor + // must reject replacement rather than discard uninspected policy. + Ok(true) + } else { + Err(error) + } +} diff --git a/crates/file-metadata/src/lib.rs b/crates/file-metadata/src/lib.rs new file mode 100644 index 0000000..4f1b48e --- /dev/null +++ b/crates/file-metadata/src/lib.rs @@ -0,0 +1,42 @@ +//! Descriptor-based extended ACL inspection for the local file editor. +//! +//! The Darwin implementation is the narrow FFI exception accepted in ADR 0006. +//! This crate never reopens paths, changes metadata, closes the caller's file +//! descriptor, or launches a subprocess. + +#![deny(unsafe_code)] +#![deny(unsafe_op_in_unsafe_fn)] + +use std::io; +use std::os::fd::AsFd; + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod darwin; + +/// Reports whether the pinned filesystem object has an extended ACL. +/// +/// The result is an observation, not a lock: another process may change metadata +/// immediately afterward. Explicit and inherited entries count as present, as +/// does an allocated empty ACL, which may carry ACL-level inheritance policy. +/// +/// # Errors +/// +/// Returns the operating system's error if the ACL cannot be inspected. On +/// platforms other than macOS, returns [`io::ErrorKind::Unsupported`]; callers +/// must use their platform-specific ACL/xattr inspection instead of treating +/// unavailable inspection as evidence that no ACL exists. +pub fn has_extended_acl(fd: impl AsFd) -> io::Result { + #[cfg(target_os = "macos")] + { + darwin::has_extended_acl(fd.as_fd()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = fd; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "descriptor ACL inspection is implemented for macOS only", + )) + } +} diff --git a/crates/file-metadata/tests/darwin_acl.rs b/crates/file-metadata/tests/darwin_acl.rs new file mode 100644 index 0000000..c303aef --- /dev/null +++ b/crates/file-metadata/tests/darwin_acl.rs @@ -0,0 +1,101 @@ +#![cfg(target_os = "macos")] + +use std::fs::{self, File}; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use cli_master_file_metadata::has_extended_acl; +use tempfile::TempDir; + +fn chmod(path: &Path, arguments: &[&str]) { + let output = Command::new("/bin/chmod") + .args(arguments) + .arg(path) + .output() + .expect("run chmod for real temporary-file ACL fixture"); + assert!( + output.status.success(), + "chmod fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn clean_directory() -> TempDir { + let directory = tempfile::tempdir().expect("temporary directory"); + chmod(directory.path(), &["-N"]); + directory +} + +#[test] +fn ordinary_file_without_acl_remains_editable() { + let directory = clean_directory(); + let path = directory.path().join("ordinary.txt"); + fs::write(&path, "ordinary text\n").unwrap(); + let file = File::open(path).unwrap(); + + assert!(!has_extended_acl(&file).unwrap()); + assert!(file.metadata().is_ok(), "inspection must not close the fd"); +} + +#[test] +fn explicit_acl_is_observed_without_modifying_content_or_mode() { + let directory = clean_directory(); + let path = directory.path().join("explicit.txt"); + fs::write(&path, "keep this content\n").unwrap(); + chmod(&path, &["+a", "everyone allow read"]); + let file = File::open(&path).unwrap(); + let permissions = file.metadata().unwrap().permissions().mode(); + + assert!(has_extended_acl(&file).unwrap()); + assert_eq!(fs::read(&path).unwrap(), b"keep this content\n"); + assert_eq!(file.metadata().unwrap().permissions().mode(), permissions); + chmod(&path, &["-N"]); + assert!(!has_extended_acl(&file).unwrap()); +} + +#[test] +fn inherited_acl_is_detected_on_new_files() { + let directory = clean_directory(); + chmod( + directory.path(), + &["+a", "everyone allow read,file_inherit,directory_inherit"], + ); + let path = directory.path().join("inherited.txt"); + fs::write(&path, "inherited policy\n").unwrap(); + let file = File::open(&path).unwrap(); + + assert!(has_extended_acl(&file).unwrap()); + chmod(&path, &["-N"]); + assert!(!has_extended_acl(&file).unwrap()); +} + +#[test] +fn inspection_follows_the_open_descriptor_after_path_replacement() { + let directory = clean_directory(); + let path = directory.path().join("document.txt"); + fs::write(&path, "original\n").unwrap(); + chmod(&path, &["+a", "everyone allow read"]); + let original = File::open(&path).unwrap(); + + fs::rename(&path, directory.path().join("moved.txt")).unwrap(); + fs::write(&path, "replacement\n").unwrap(); + let replacement = File::open(&path).unwrap(); + + assert!(has_extended_acl(&original).unwrap()); + assert!(!has_extended_acl(&replacement).unwrap()); +} + +#[test] +fn denied_acl_inspection_is_an_error_instead_of_missing_acl() { + let directory = clean_directory(); + let path = directory.path().join("denied.txt"); + fs::write(&path, "protected security metadata\n").unwrap(); + let file = File::open(&path).unwrap(); + chmod(&path, &["+a", "everyone deny readsecurity"]); + + let result = has_extended_acl(&file); + chmod(&path, &["-N"]); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); +} diff --git a/crates/file-metadata/tests/unsupported.rs b/crates/file-metadata/tests/unsupported.rs new file mode 100644 index 0000000..585e6cf --- /dev/null +++ b/crates/file-metadata/tests/unsupported.rs @@ -0,0 +1,14 @@ +#![cfg(not(target_os = "macos"))] + +use std::io; + +use cli_master_file_metadata::has_extended_acl; + +#[test] +fn unavailable_inspection_is_an_error_instead_of_missing_acl() { + let file = tempfile::tempfile().unwrap(); + assert_eq!( + has_extended_acl(&file).unwrap_err().kind(), + io::ErrorKind::Unsupported + ); +} diff --git a/crates/session/src/runtime/process_tree.rs b/crates/session/src/runtime/process_tree.rs index 50eca30..04bc9b5 100644 --- a/crates/session/src/runtime/process_tree.rs +++ b/crates/session/src/runtime/process_tree.rs @@ -1,7 +1,10 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, io, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; @@ -12,11 +15,13 @@ const SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(20); #[derive(Clone)] struct CachedSnapshot { + sequence: u64, captured_at: Instant, records: Arc<[ProcessRecord]>, } static PROCESS_SNAPSHOT_CACHE: OnceLock>> = OnceLock::new(); +static NEXT_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Eq, PartialEq)] struct ProcessIdentity { @@ -40,6 +45,7 @@ pub(super) struct TrackedProcess { pub(super) struct ProcessTree { known: BTreeMap, + latest_snapshot_sequence: u64, scan_timeout: Duration, max_tracked_processes: usize, } @@ -50,32 +56,45 @@ impl ProcessTree { scan_timeout: Duration, max_tracked_processes: usize, ) -> io::Result { - let records = process_snapshot(scan_timeout, true)?; + let snapshot = process_snapshot(scan_timeout, true)?; let root_pid = root_pid.as_raw(); let mut tree = Self { known: BTreeMap::new(), + latest_snapshot_sequence: snapshot.sequence, scan_timeout, max_tracked_processes, }; - if let Some(root) = records + if let Some(root) = snapshot .records .iter() .find(|record| record.identity.pid == root_pid && !record.zombie) { tree.known.insert(root_pid, root.identity.clone()); } - let _ = tree.absorb(&records.records)?; + let _ = tree.absorb_snapshot(&snapshot)?; Ok(tree) } pub fn refresh(&mut self) -> io::Result> { - let snapshot = process_snapshot(self.scan_timeout, false)?; - self.absorb(&snapshot.records) + // A global scan can begin before this tree's latest evidence and + // publish afterward. Never let that older view prune a proven process. + let snapshot = process_snapshot_after( + self.scan_timeout, + false, + Some(self.latest_snapshot_sequence), + )?; + self.absorb_snapshot(&snapshot) } pub fn refresh_fresh(&mut self) -> io::Result> { let snapshot = process_snapshot(self.scan_timeout, true)?; - self.absorb(&snapshot.records) + self.absorb_snapshot(&snapshot) + } + + fn absorb_snapshot(&mut self, snapshot: &CachedSnapshot) -> io::Result> { + let processes = self.absorb(&snapshot.records)?; + self.latest_snapshot_sequence = self.latest_snapshot_sequence.max(snapshot.sequence); + Ok(processes) } fn absorb(&mut self, records: &[ProcessRecord]) -> io::Result> { @@ -144,6 +163,7 @@ impl ProcessTree { fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { Self { known: BTreeMap::from([(root.identity.pid, root.identity)]), + latest_snapshot_sequence: 1, scan_timeout: Duration::from_secs(1), max_tracked_processes, } @@ -151,7 +171,16 @@ impl ProcessTree { } fn process_snapshot(timeout: Duration, force: bool) -> io::Result { + process_snapshot_after(timeout, force, None) +} + +fn process_snapshot_after( + timeout: Duration, + force: bool, + minimum_sequence: Option, +) -> io::Result { let started = Instant::now(); + let sequence = next_snapshot_sequence()?; let cache = PROCESS_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); if !force { let Some(cached) = cache.try_lock_for(timeout) else { @@ -161,7 +190,7 @@ fn process_snapshot(timeout: Duration, force: bool) -> io::Result io::Result io::Result io::Result { + // The counter is only an ordering token; the cache mutex publishes the + // snapshot data, so no cross-thread memory ordering is required here. + NEXT_SNAPSHOT_SEQUENCE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |sequence| { + sequence.checked_add(1) + }) + .map_err(|_| io::Error::other("process-tree snapshot sequence exhausted")) +} + +fn cached_snapshot_is_usable(snapshot: &CachedSnapshot, minimum_sequence: Option) -> bool { + snapshot.captured_at.elapsed() <= SNAPSHOT_CACHE_TTL + && minimum_sequence.is_none_or(|minimum| snapshot.sequence >= minimum) +} + +fn publish_snapshot(cache: &mut Option, snapshot: CachedSnapshot) { + let should_publish = cache + .as_ref() + .is_none_or(|cached| snapshot.sequence > cached.sequence); + if should_publish { + *cache = Some(snapshot); + } +} + #[cfg(target_os = "linux")] fn scan_processes_uncached(timeout: Duration) -> io::Result> { const MAX_PROCESS_SNAPSHOT_RECORDS: usize = 262_144; @@ -365,6 +421,57 @@ mod tests { } } + fn snapshot( + sequence: u64, + captured_at: Instant, + records: impl Into>, + ) -> CachedSnapshot { + CachedSnapshot { + sequence, + captured_at, + records: records.into(), + } + } + + #[test] + fn out_of_order_scan_cannot_replace_a_newer_cached_snapshot() { + let newer = snapshot(2, Instant::now(), vec![record(200, 1, 200, "newer")]); + let older = snapshot(1, Instant::now(), vec![record(100, 1, 100, "older")]); + let mut cache = Some(newer); + + publish_snapshot(&mut cache, older); + + let cached = cache.expect("newer cache entry should be retained"); + assert_eq!(cached.sequence, 2); + assert_eq!(cached.records[0].identity.pid, 200); + } + + #[test] + fn cached_snapshot_from_before_latest_tree_evidence_is_not_usable() { + let stale = snapshot(1, Instant::now(), Vec::::new()); + + assert!(!cached_snapshot_is_usable(&stale, Some(2))); + } + + #[test] + fn fresh_snapshot_advances_the_tree_cache_floor() { + let root = record(100, 1, 100, "root"); + let child = record(101, 100, 101, "child"); + let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); + let newer = snapshot(3, Instant::now(), vec![root.clone(), child]); + + tree.absorb_snapshot(&newer) + .expect("newer process-tree evidence should be accepted"); + + let stale = snapshot(2, Instant::now(), vec![root]); + assert_eq!(tree.latest_snapshot_sequence, 3); + assert!(tree.known.contains_key(&101)); + assert!(!cached_snapshot_is_usable( + &stale, + Some(tree.latest_snapshot_sequence) + )); + } + #[test] fn only_proven_descendants_are_retained_across_group_changes() { let root = record(100, 1, 100, "root"); diff --git a/crates/storage/migrations/0005_organization.sql b/crates/storage/migrations/0005_organization.sql new file mode 100644 index 0000000..99a9eb6 --- /dev/null +++ b/crates/storage/migrations/0005_organization.sql @@ -0,0 +1,25 @@ +-- User organization metadata never alters daemon-owned process state. +CREATE TABLE project_organization ( + project_id TEXT PRIMARY KEY NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + pinned INTEGER NOT NULL CHECK (typeof(pinned) = 'integer' AND pinned IN (0, 1)), + archived INTEGER NOT NULL CHECK (typeof(archived) = 'integer' AND archived IN (0, 1)), + revision INTEGER NOT NULL CHECK ( + typeof(revision) = 'integer' AND revision BETWEEN 1 AND 9007199254740991 + ), + updated_at_ms INTEGER NOT NULL CHECK ( + typeof(updated_at_ms) = 'integer' AND updated_at_ms BETWEEN 0 AND 9007199254740991 + ) +); + +CREATE TABLE session_organization ( + session_id TEXT PRIMARY KEY NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + pinned INTEGER NOT NULL CHECK (typeof(pinned) = 'integer' AND pinned IN (0, 1)), + archived INTEGER NOT NULL CHECK (typeof(archived) = 'integer' AND archived IN (0, 1)), + workflow TEXT NOT NULL CHECK (workflow IN ('backlog', 'in_progress', 'in_review', 'blocked', 'done')), + revision INTEGER NOT NULL CHECK ( + typeof(revision) = 'integer' AND revision BETWEEN 1 AND 9007199254740991 + ), + updated_at_ms INTEGER NOT NULL CHECK ( + typeof(updated_at_ms) = 'integer' AND updated_at_ms BETWEEN 0 AND 9007199254740991 + ) +); diff --git a/crates/storage/src/durability_tests.rs b/crates/storage/src/durability_tests.rs index f29fc29..9fbfc4d 100644 --- a/crates/storage/src/durability_tests.rs +++ b/crates/storage/src/durability_tests.rs @@ -36,6 +36,7 @@ fn configured_file_database_uses_full_wal_and_verified_schema() { (2, "worktree_dirty_state"), (3, "recovery_metadata"), (4, "knowledge_documents"), + (5, "organization"), ] ); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 63572a3..8281ca1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -11,6 +11,7 @@ mod error; pub mod knowledge; mod migrate; mod models; +pub mod organization; mod paths; mod projects; mod recovery; @@ -34,10 +35,11 @@ pub use connection::Storage; pub use error::StorageError; pub use knowledge::KnowledgeStorageError; pub use models::{SessionRuntimeUpdate, StoredAgent, StoredSession, StoredWorktree, WorktreeState}; +pub use organization::OrganizationStorageError; pub use recovery::{ReconciliationEvent, ReconciliationReason, RecoveryContext}; /// The newest schema version understood by this crate. -pub const LATEST_SCHEMA_VERSION: u32 = 4; +pub const LATEST_SCHEMA_VERSION: u32 = 5; impl Storage { /// Opens and configures a file-backed `SQLite` database. diff --git a/crates/storage/src/migrate.rs b/crates/storage/src/migrate.rs index ce4e2c1..427c3ce 100644 --- a/crates/storage/src/migrate.rs +++ b/crates/storage/src/migrate.rs @@ -39,9 +39,36 @@ const MIGRATIONS: &[Migration] = &[ sql: include_str!("../migrations/0004_knowledge_documents.sql"), destructive: false, }, + Migration { + version: 5, + name: "organization", + sql: include_str!("../migrations/0005_organization.sql"), + destructive: false, + }, ]; const REQUIRED_TABLES: &[(&str, &[&str])] = &[ + ( + "project_organization", + &[ + "project_id", + "pinned", + "archived", + "revision", + "updated_at_ms", + ], + ), + ( + "session_organization", + &[ + "session_id", + "pinned", + "archived", + "workflow", + "revision", + "updated_at_ms", + ], + ), ( "projects", &["id", "name", "path", "created_at", "last_opened_at"], diff --git a/crates/storage/src/organization/mod.rs b/crates/storage/src/organization/mod.rs new file mode 100644 index 0000000..c0ede76 --- /dev/null +++ b/crates/storage/src/organization/mod.rs @@ -0,0 +1,349 @@ +//! Revisioned organization metadata with snapshot reads and atomic writes. + +use std::{collections::BTreeMap, error::Error, fmt}; + +use cli_master_core::{ + ApiError, + organization::{ + MAX_ORGANIZATION_REVISION, OrganizationEntry, OrganizationGetRequest, + OrganizationGetResponse, OrganizationSaveRequest, OrganizationTarget, + OrganizationValidationError, OrganizationWorkflow, + }, +}; +use rusqlite::{Connection, Row, TransactionBehavior, params, params_from_iter}; + +use crate::{Storage, StorageError}; + +/// Organization repository failure containing no process or user-text details. +pub enum OrganizationStorageError { + /// Caller supplied a target/workflow or revision mismatch. + InvalidInput(OrganizationValidationError), + /// Clock value cannot be represented safely on the wire. + InvalidTimestamp, + /// A selected project is no longer registered. + ProjectNotFound, + /// A selected session no longer exists. + SessionNotFound, + /// The observed revision is stale. + Conflict, + /// An existing row cannot advance beyond the JavaScript-safe range. + RevisionExhausted, + /// Persisted organization metadata violates the typed contract. + CorruptData, + /// The metadata database could not complete the operation. + Storage(StorageError), +} + +impl OrganizationStorageError { + /// Returns a stable IPC error without underlying SQL or parameters. + #[must_use] + pub fn to_api_error(&self) -> ApiError { + let (code, action) = match self { + Self::InvalidInput(_) | Self::InvalidTimestamp => ( + "invalid_payload", + "Correct the organization values and retry", + ), + Self::ProjectNotFound => ("project_not_found", "Select a registered project"), + Self::SessionNotFound => ("session_not_found", "Select an existing session"), + Self::Conflict => ( + "organization_conflict", + "Reload current organization values before saving again", + ), + Self::RevisionExhausted => ( + "organization_revision_exhausted", + "The organization revision cannot be advanced", + ), + Self::CorruptData => ( + "organization_corrupt_data", + "Restore a known-good database backup", + ), + Self::Storage(error) => return error.to_api_error(), + }; + ApiError::new(code, self.to_string()).with_action(action) + } +} + +impl fmt::Display for OrganizationStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(error) => error.fmt(formatter), + Self::InvalidTimestamp => { + formatter.write_str("Organization timestamp is outside the supported range") + } + Self::ProjectNotFound => formatter.write_str("The selected project no longer exists"), + Self::SessionNotFound => formatter.write_str("The selected session no longer exists"), + Self::Conflict => { + formatter.write_str("Organization values changed since they were loaded") + } + Self::RevisionExhausted => { + formatter.write_str("The organization revision cannot be advanced") + } + Self::CorruptData => { + formatter.write_str("Stored organization values violate the supported contract") + } + Self::Storage(error) => fmt::Display::fmt(error, formatter), + } + } +} + +impl fmt::Debug for OrganizationStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl Error for OrganizationStorageError {} + +impl From for OrganizationStorageError { + fn from(value: StorageError) -> Self { + Self::Storage(value) + } +} + +impl From for OrganizationStorageError { + fn from(value: rusqlite::Error) -> Self { + Self::Storage(StorageError::Database(value)) + } +} + +impl From for OrganizationStorageError { + fn from(value: OrganizationValidationError) -> Self { + Self::InvalidInput(value) + } +} + +impl Storage { + /// Reads one consistent batch in request order without persisting defaults. + /// + /// At most two set-based queries read the requested project/session groups; + /// missing entities fail the complete batch. No process fields are modified. + /// + /// # Errors + /// + /// Returns an error for missing entities, corrupt rows, or database failure. + pub fn get_organization( + &self, + request: &OrganizationGetRequest, + ) -> Result { + self.with_connection_mut("get organization", |connection| { + Ok(get_organization(connection, request)) + })? + } + + /// Replaces complete organization values with an atomic revision comparison. + /// + /// The first explicit save creates revision one. Clock rollback never + /// decreases an existing timestamp. Pin/archive/workflow changes have no + /// effect on session status, processes, worktrees, or saved knowledge. + /// + /// # Errors + /// + /// Returns an error for invalid input, missing entities, stale/exhausted + /// revisions, corrupt rows, or database failure. Failures roll back fully. + pub fn save_organization( + &self, + request: &OrganizationSaveRequest, + now: i64, + ) -> Result { + request.validate()?; + if !(0..=9_007_199_254_740_991).contains(&now) { + return Err(OrganizationStorageError::InvalidTimestamp); + } + self.with_connection_mut("save organization", |connection| { + Ok(save_organization(connection, request, now)) + })? + } +} + +fn get_organization( + connection: &mut Connection, + request: &OrganizationGetRequest, +) -> Result { + let transaction = connection.transaction()?; + let mut projects = Vec::new(); + let mut sessions = Vec::new(); + for target in request.targets.as_slice() { + match target { + OrganizationTarget::Project { id } => projects.push(id.to_string()), + OrganizationTarget::Session { id } => sessions.push(id.to_string()), + } + } + let mut found = read_group(&transaction, Group::Project, &projects)?; + found.extend(read_group(&transaction, Group::Session, &sessions)?); + let entries = request + .targets + .as_slice() + .iter() + .map(|target| found.remove(target).ok_or_else(|| missing(*target))) + .collect::, _>>()?; + transaction.commit()?; + Ok(OrganizationGetResponse { entries }) +} + +fn save_organization( + connection: &mut Connection, + request: &OrganizationSaveRequest, + now: i64, +) -> Result { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (group, id) = match request.target { + OrganizationTarget::Project { id } => (Group::Project, id.to_string()), + OrganizationTarget::Session { id } => (Group::Session, id.to_string()), + }; + let current = read_group(&transaction, group, std::slice::from_ref(&id))? + .remove(&request.target) + .ok_or_else(|| missing(request.target))?; + if current.revision != request.expected_revision { + return Err(OrganizationStorageError::Conflict); + } + if current.revision == MAX_ORGANIZATION_REVISION { + return Err(OrganizationStorageError::RevisionExhausted); + } + let entry = OrganizationEntry { + target: request.target, + pinned: request.pinned, + archived: request.archived, + workflow: request.workflow, + revision: current.revision + 1, + updated_at_ms: Some( + current + .updated_at_ms + .map_or(now, |updated| now.max(updated)), + ), + }; + entry.validate()?; + let revision = + i64::try_from(entry.revision).map_err(|_| OrganizationStorageError::RevisionExhausted)?; + let previous = + i64::try_from(current.revision).map_err(|_| OrganizationStorageError::RevisionExhausted)?; + let changed = match (group, current.revision == 0) { + (Group::Project, true) => transaction.execute( + "INSERT INTO project_organization (project_id, pinned, archived, revision, updated_at_ms) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, entry.pinned, entry.archived, revision, entry.updated_at_ms], + )?, + (Group::Project, false) => transaction.execute( + "UPDATE project_organization SET pinned = ?2, archived = ?3, revision = ?4, updated_at_ms = ?5 WHERE project_id = ?1 AND revision = ?6", + params![id, entry.pinned, entry.archived, revision, entry.updated_at_ms, previous], + )?, + (Group::Session, true) => transaction.execute( + "INSERT INTO session_organization (session_id, pinned, archived, workflow, revision, updated_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, entry.pinned, entry.archived, entry.workflow.map(workflow_name), revision, entry.updated_at_ms], + )?, + (Group::Session, false) => transaction.execute( + "UPDATE session_organization SET pinned = ?2, archived = ?3, workflow = ?4, revision = ?5, updated_at_ms = ?6 WHERE session_id = ?1 AND revision = ?7", + params![id, entry.pinned, entry.archived, entry.workflow.map(workflow_name), revision, entry.updated_at_ms, previous], + )?, + }; + if changed != 1 { + return Err(OrganizationStorageError::Conflict); + } + transaction.commit()?; + Ok(entry) +} + +#[derive(Clone, Copy)] +enum Group { + Project, + Session, +} + +fn read_group( + connection: &Connection, + group: Group, + ids: &[String], +) -> Result, OrganizationStorageError> { + let mut found = BTreeMap::new(); + if ids.is_empty() { + return Ok(found); + } + // Only generated placeholder punctuation enters SQL; every ID is bound. + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let sql = match group { + Group::Project => format!( + "SELECT p.id, COALESCE(o.pinned, 0), COALESCE(o.archived, 0), NULL, COALESCE(o.revision, 0), o.updated_at_ms + FROM projects p LEFT JOIN project_organization o ON o.project_id = p.id WHERE p.id IN ({placeholders})" + ), + Group::Session => format!( + "SELECT s.id, COALESCE(o.pinned, 0), COALESCE(o.archived, 0), COALESCE(o.workflow, 'backlog'), COALESCE(o.revision, 0), o.updated_at_ms + FROM sessions s LEFT JOIN session_organization o ON o.session_id = s.id WHERE s.id IN ({placeholders})" + ), + }; + let mut statement = connection.prepare(&sql)?; + let mut rows = statement.query(params_from_iter(ids))?; + while let Some(row) = rows.next()? { + let entry = decode_entry(row, group)?; + found.insert(entry.target, entry); + } + Ok(found) +} + +fn decode_entry( + row: &Row<'_>, + group: Group, +) -> Result { + let id: String = row.get(0)?; + let target = match group { + Group::Project => OrganizationTarget::Project { + id: id + .parse() + .map_err(|_| OrganizationStorageError::CorruptData)?, + }, + Group::Session => OrganizationTarget::Session { + id: id + .parse() + .map_err(|_| OrganizationStorageError::CorruptData)?, + }, + }; + let workflow: Option = row.get(3)?; + let revision: i64 = row.get(4)?; + let entry = OrganizationEntry { + target, + pinned: decode_bool(row.get(1)?)?, + archived: decode_bool(row.get(2)?)?, + workflow: workflow.as_deref().map(decode_workflow).transpose()?, + revision: u64::try_from(revision).map_err(|_| OrganizationStorageError::CorruptData)?, + updated_at_ms: row.get(5)?, + }; + entry + .validate() + .map_err(|_| OrganizationStorageError::CorruptData)?; + Ok(entry) +} + +fn decode_bool(value: i64) -> Result { + match value { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(OrganizationStorageError::CorruptData), + } +} + +fn missing(target: OrganizationTarget) -> OrganizationStorageError { + match target { + OrganizationTarget::Project { .. } => OrganizationStorageError::ProjectNotFound, + OrganizationTarget::Session { .. } => OrganizationStorageError::SessionNotFound, + } +} + +const fn workflow_name(workflow: OrganizationWorkflow) -> &'static str { + match workflow { + OrganizationWorkflow::Backlog => "backlog", + OrganizationWorkflow::InProgress => "in_progress", + OrganizationWorkflow::InReview => "in_review", + OrganizationWorkflow::Blocked => "blocked", + OrganizationWorkflow::Done => "done", + } +} + +fn decode_workflow(value: &str) -> Result { + match value { + "backlog" => Ok(OrganizationWorkflow::Backlog), + "in_progress" => Ok(OrganizationWorkflow::InProgress), + "in_review" => Ok(OrganizationWorkflow::InReview), + "blocked" => Ok(OrganizationWorkflow::Blocked), + "done" => Ok(OrganizationWorkflow::Done), + _ => Err(OrganizationStorageError::CorruptData), + } +} diff --git a/crates/storage/tests/organization.rs b/crates/storage/tests/organization.rs new file mode 100644 index 0000000..1d9702b --- /dev/null +++ b/crates/storage/tests/organization.rs @@ -0,0 +1,492 @@ +mod common; + +use std::{ + fs, + sync::{Arc, Barrier}, + thread, +}; + +use cli_master_core::{ + AgentSource, Project, ProjectId, SessionStatus, + organization::{ + MAX_ORGANIZATION_REVISION, OrganizationEntry, OrganizationGetRequest, + OrganizationSaveRequest, OrganizationTarget, OrganizationTargets, OrganizationWorkflow, + }, +}; +use cli_master_storage::{Storage, StoredSession, organization::OrganizationStorageError}; +use rusqlite::{Connection, TransactionBehavior, params}; +use tempfile::TempDir; + +const NOW: i64 = 1_788_600_000_000; + +struct Fixture { + root: TempDir, + storage: Storage, + project: Project, + session: StoredSession, +} + +impl Fixture { + fn new() -> Self { + let root = TempDir::new().unwrap(); + let storage = Storage::open_migrated(root.path().join("organization.db")).unwrap(); + let project = common::project("Organization", root.path().join("project")); + fs::create_dir_all(&project.path).unwrap(); + fs::write(project.path.join("keep.txt"), b"Repository content").unwrap(); + storage.insert_project(&project).unwrap(); + let agent = common::agent(AgentSource::BuiltIn, "Agent"); + storage.insert_agent(&agent).unwrap(); + let session = common::session( + project.id, + agent.id, + SessionStatus::Running, + Some("organization-daemon"), + Some(4_343), + ); + storage.insert_session(&session).unwrap(); + Self { + root, + storage, + project, + session, + } + } + + fn project_target(&self) -> OrganizationTarget { + OrganizationTarget::Project { + id: self.project.id, + } + } + fn session_target(&self) -> OrganizationTarget { + OrganizationTarget::Session { + id: self.session.id, + } + } + fn connection(&self) -> Connection { + Connection::open(self.root.path().join("organization.db")).unwrap() + } +} + +fn get(targets: Vec) -> OrganizationGetRequest { + OrganizationGetRequest { + targets: OrganizationTargets::try_new(targets).unwrap(), + } +} + +fn save(entry: &OrganizationEntry, pinned: bool, archived: bool) -> OrganizationSaveRequest { + OrganizationSaveRequest { + target: entry.target, + expected_revision: entry.revision, + pinned, + archived, + workflow: entry.workflow, + } +} + +fn count_rows(connection: &Connection) -> i64 { + connection.query_row("SELECT (SELECT COUNT(*) FROM project_organization) + (SELECT COUNT(*) FROM session_organization)", [], |row| row.get(0)).unwrap() +} + +#[test] +fn defaults_preserve_request_order_without_persisting_rows() { + let fixture = Fixture::new(); + let targets = vec![fixture.session_target(), fixture.project_target()]; + let expected = targets + .iter() + .copied() + .map(OrganizationEntry::defaults) + .collect::>(); + let raw = fixture.connection(); + assert_eq!(count_rows(&raw), 0); + for _ in 0..3 { + assert_eq!( + fixture + .storage + .get_organization(&get(targets.clone())) + .unwrap() + .entries, + expected + ); + } + assert_eq!(expected[0].workflow, Some(OrganizationWorkflow::Backlog)); + assert_eq!(expected[1].workflow, None); + assert_eq!(count_rows(&raw), 0); +} + +#[test] +fn saves_survive_reopen_and_returning_to_defaults_retains_revision() { + let fixture = Fixture::new(); + let project_default = OrganizationEntry::defaults(fixture.project_target()); + let project = fixture + .storage + .save_organization(&save(&project_default, true, true), NOW) + .unwrap(); + assert_eq!(project.revision, 1); + assert_eq!(project.updated_at_ms, Some(NOW)); + let mut session_request = save( + &OrganizationEntry::defaults(fixture.session_target()), + false, + true, + ); + session_request.workflow = Some(OrganizationWorkflow::InProgress); + let mut session = fixture + .storage + .save_organization(&session_request, NOW) + .unwrap(); + for workflow in [ + OrganizationWorkflow::InReview, + OrganizationWorkflow::Blocked, + OrganizationWorkflow::Done, + ] { + let mut request = save(&session, true, false); + request.workflow = Some(workflow); + session = fixture + .storage + .save_organization(&request, NOW + 1) + .unwrap(); + } + let restored = fixture + .storage + .save_organization(&save(&project, false, false), NOW - 100) + .unwrap(); + assert_eq!(restored.revision, 2); + assert_eq!(restored.updated_at_ms, Some(NOW)); + assert_eq!(count_rows(&fixture.connection()), 2); + fixture.storage.close().unwrap(); + let path = fixture.root.path().join("organization.db"); + drop(fixture.storage); + let reopened = Storage::open_migrated(path).unwrap(); + assert_eq!( + reopened + .get_organization(&get(vec![session.target, restored.target])) + .unwrap() + .entries, + vec![session, restored] + ); +} + +#[test] +fn archiving_running_session_and_workflow_changes_preserve_native_metadata_and_files() { + let fixture = Fixture::new(); + let original_project = fixture.storage.get_project(fixture.project.id).unwrap(); + let original_session = fixture.storage.get_session(fixture.session.id).unwrap(); + let mut request = save( + &OrganizationEntry::defaults(fixture.session_target()), + true, + true, + ); + request.workflow = Some(OrganizationWorkflow::Done); + let entry = fixture.storage.save_organization(&request, NOW).unwrap(); + assert!(entry.archived); + assert_eq!( + fixture.storage.get_project(fixture.project.id).unwrap(), + original_project + ); + assert_eq!( + fixture.storage.get_session(fixture.session.id).unwrap(), + original_session + ); + assert_eq!( + fs::read(fixture.project.path.join("keep.txt")).unwrap(), + b"Repository content" + ); +} + +fn compete( + path: &std::path::Path, + first: OrganizationSaveRequest, + second: OrganizationSaveRequest, +) -> Vec> { + let first_storage = Storage::open_migrated(path).unwrap(); + let second_storage = Storage::open_migrated(path).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let handles = [(first_storage, first), (second_storage, second)] + .into_iter() + .map(|(storage, request)| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + storage.save_organization(&request, NOW) + }) + }) + .collect::>(); + barrier.wait(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() +} + +#[test] +fn independent_connections_resolve_first_insert_and_update_races_with_one_winner() { + let fixture = Fixture::new(); + let path = fixture.root.path().join("organization.db"); + let defaults = OrganizationEntry::defaults(fixture.project_target()); + for baseline in [ + defaults.clone(), + OrganizationEntry { + pinned: true, + revision: 1, + updated_at_ms: Some(NOW), + ..defaults + }, + ] { + let outcomes = compete( + &path, + save(&baseline, true, false), + save(&baseline, false, true), + ); + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(OrganizationStorageError::Conflict))) + .count(), + 1 + ); + let winner = fixture + .storage + .get_organization(&get(vec![baseline.target])) + .unwrap() + .entries + .remove(0); + assert_eq!(winner.revision, baseline.revision + 1); + assert_ne!(winner.pinned, winner.archived); + let stale = fixture + .storage + .save_organization(&save(&baseline, false, false), NOW) + .unwrap_err(); + assert_eq!(stale.to_api_error().code, "organization_conflict"); + assert_eq!( + fixture + .storage + .get_organization(&get(vec![baseline.target])) + .unwrap() + .entries, + vec![winner] + ); + } +} + +#[test] +fn mixed_batches_observe_one_snapshot_during_concurrent_atomic_updates() { + let fixture = Fixture::new(); + for target in [fixture.project_target(), fixture.session_target()] { + fixture + .storage + .save_organization( + &save(&OrganizationEntry::defaults(target), false, false), + NOW, + ) + .unwrap(); + } + let path = fixture.root.path().join("organization.db"); + let project_id = fixture.project.id.to_string(); + let session_id = fixture.session.id.to_string(); + let barrier = Arc::new(Barrier::new(2)); + let writer_barrier = Arc::clone(&barrier); + let writer = thread::spawn(move || { + let mut connection = Connection::open(path).unwrap(); + connection + .busy_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + writer_barrier.wait(); + for index in 0..80 { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + transaction.execute("UPDATE project_organization SET pinned = ?1, revision = revision + 1 WHERE project_id = ?2", params![index % 2, project_id]).unwrap(); + thread::yield_now(); + transaction.execute("UPDATE session_organization SET pinned = ?1, revision = revision + 1 WHERE session_id = ?2", params![index % 2, session_id]).unwrap(); + transaction.commit().unwrap(); + } + }); + barrier.wait(); + let request = get(vec![fixture.session_target(), fixture.project_target()]); + for _ in 0..100 { + let entries = fixture.storage.get_organization(&request).unwrap().entries; + assert_eq!(entries[0].revision, entries[1].revision); + assert_eq!(entries[0].pinned, entries[1].pinned); + } + writer.join().unwrap(); +} + +#[test] +fn unknown_entities_abort_whole_batches_and_failed_saves_create_nothing() { + let fixture = Fixture::new(); + for target in [ + OrganizationTarget::Project { + id: ProjectId::new(), + }, + OrganizationTarget::Session { + id: cli_master_core::SessionId::new(), + }, + ] { + let expected = match target { + OrganizationTarget::Project { .. } => "project_not_found", + OrganizationTarget::Session { .. } => "session_not_found", + }; + let error = fixture + .storage + .get_organization(&get(vec![ + fixture.project_target(), + target, + fixture.session_target(), + ])) + .unwrap_err(); + assert_eq!(error.to_api_error().code, expected); + let error = fixture + .storage + .save_organization(&save(&OrganizationEntry::defaults(target), true, true), NOW) + .unwrap_err(); + assert_eq!(error.to_api_error().code, expected); + } + assert_eq!(count_rows(&fixture.connection()), 0); +} + +#[test] +fn foreign_keys_remove_only_organization_rows_when_native_metadata_is_removed() { + let fixture = Fixture::new(); + for target in [fixture.project_target(), fixture.session_target()] { + fixture + .storage + .save_organization(&save(&OrganizationEntry::defaults(target), true, true), NOW) + .unwrap(); + } + fixture + .storage + .remove_session_metadata(fixture.session.id) + .unwrap(); + assert_eq!(count_rows(&fixture.connection()), 1); + fixture + .storage + .remove_project_metadata(fixture.project.id) + .unwrap(); + assert_eq!(count_rows(&fixture.connection()), 0); + assert_eq!( + fs::read(fixture.project.path.join("keep.txt")).unwrap(), + b"Repository content" + ); +} + +#[test] +fn validation_constraints_and_revision_exhaustion_preserve_previous_values() { + let fixture = Fixture::new(); + let defaults = OrganizationEntry::defaults(fixture.project_target()); + let mut invalid = save(&defaults, true, false); + invalid.workflow = Some(OrganizationWorkflow::Done); + assert!(fixture.storage.save_organization(&invalid, NOW).is_err()); + invalid.workflow = None; + for now in [-1, 9_007_199_254_740_992] { + assert!(fixture.storage.save_organization(&invalid, now).is_err()); + } + let saved = fixture.storage.save_organization(&invalid, NOW).unwrap(); + let raw = fixture.connection(); + for sql in [ + "UPDATE project_organization SET pinned = 2", + "UPDATE project_organization SET archived = 'false'", + "UPDATE project_organization SET revision = 0", + "UPDATE project_organization SET revision = 9007199254740992", + "UPDATE project_organization SET updated_at_ms = -1", + ] { + assert!(raw.execute(sql, []).is_err()); + } + assert_eq!( + fixture + .storage + .get_organization(&get(vec![saved.target])) + .unwrap() + .entries, + vec![saved.clone()] + ); + raw.execute( + "UPDATE project_organization SET revision = ?1", + [i64::try_from(MAX_ORGANIZATION_REVISION).unwrap()], + ) + .unwrap(); + let exhausted = OrganizationEntry { + revision: MAX_ORGANIZATION_REVISION, + ..saved + }; + assert!(matches!( + fixture + .storage + .save_organization(&save(&exhausted, false, false), NOW + 1), + Err(OrganizationStorageError::RevisionExhausted) + )); + assert_eq!( + fixture + .storage + .get_organization(&get(vec![exhausted.target])) + .unwrap() + .entries, + vec![exhausted] + ); +} + +#[test] +fn corrupt_workflow_is_rejected_without_exposing_persisted_values() { + let fixture = Fixture::new(); + fixture + .storage + .save_organization( + &save( + &OrganizationEntry::defaults(fixture.session_target()), + false, + false, + ), + NOW, + ) + .unwrap(); + let raw = fixture.connection(); + raw.execute_batch("PRAGMA ignore_check_constraints = ON") + .unwrap(); + raw.execute( + "UPDATE session_organization SET workflow = ?1", + ["PRIVATE_CORRUPT_SENTINEL"], + ) + .unwrap(); + let error = fixture + .storage + .get_organization(&get(vec![ + fixture.project_target(), + fixture.session_target(), + ])) + .unwrap_err(); + assert!(matches!(error, OrganizationStorageError::CorruptData)); + assert!( + !format!("{error:?} {}", error.to_api_error().message).contains("PRIVATE_CORRUPT_SENTINEL") + ); +} + +#[test] +fn database_constraints_protect_typed_parents_and_session_workflow() { + let fixture = Fixture::new(); + let raw = fixture.connection(); + raw.pragma_update(None, "foreign_keys", true).unwrap(); + let invalid_workflow = raw.execute( + "INSERT INTO session_organization (session_id,pinned,archived,workflow,revision,updated_at_ms) VALUES (?1,0,0,'running',1,0)", + [fixture.session.id.to_string()], + ).unwrap_err(); + assert_eq!( + invalid_workflow.sqlite_error().unwrap().extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_CHECK + ); + let wrong_parent = raw.execute( + "INSERT INTO project_organization (project_id,pinned,archived,revision,updated_at_ms) VALUES (?1,0,0,1,0)", + [fixture.session.id.to_string()], + ).unwrap_err(); + assert_eq!( + wrong_parent.sqlite_error().unwrap().extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY + ); + let wrong_parent = raw.execute( + "INSERT INTO session_organization (session_id,pinned,archived,workflow,revision,updated_at_ms) VALUES (?1,0,0,'backlog',1,0)", + [fixture.project.id.to_string()], + ).unwrap_err(); + assert_eq!( + wrong_parent.sqlite_error().unwrap().extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY + ); + assert_eq!(count_rows(&raw), 0); +} diff --git a/docs/adr/0005-local-knowledge.md b/docs/adr/0005-local-knowledge.md index d6f2349..558c945 100644 --- a/docs/adr/0005-local-knowledge.md +++ b/docs/adr/0005-local-knowledge.md @@ -1,7 +1,9 @@ # ADR 0005: Local prompts, reusable context, and organization -Status: accepted for the first prompt/context increment after S2 agreement. -Organization and filesystem details remain integration dependencies. +Status: accepted for prompt/context, bounded source discovery and organization +after S2 acknowledgments. Generic source editing and runtime delivery remain +integration dependencies. Organization uses migration 0005 and separate +organization.get/save contracts; see the approved coordination documents. ## Context @@ -42,8 +44,10 @@ They neither replace process status nor signal a process. Workflow states are separate flag. The runtime owner coordinates their shared types/storage. Rule/skill discovery inventories explicitly supported locations with origin, -format, scope, and precedence explanations. It reuses the runtime owner's -file-access boundary; discovered content is data and is never executed. +format, scope, and precedence explanations. S2 approved a narrow bounded reader +inside daemon::knowledge while an adapter to the common file service remains +pending; it does not introduce a generic editor. Discovered content is data +and is never executed. Credential paths are excluded. Symlinks, external changes, size/count limits, encoding, and edit conflicts must have explicit behavior and real filesystem tests. Reference support for symlinked skill directories is a parity item, diff --git a/docs/adr/0006-local-files-and-editor.md b/docs/adr/0006-local-files-and-editor.md new file mode 100644 index 0000000..52e2680 --- /dev/null +++ b/docs/adr/0006-local-files-and-editor.md @@ -0,0 +1,336 @@ +# ADR 0006: Local files and editor I/O + +## Status + +Accepted for the requested local runtime increment, 2026-09-05. + +This decision covers the first functional slice of M29/M30 and the shared +reader needed by M07/X06. It extends the Beta domain-operation inventory with +local file editing. It does not change IPC envelope version 1, session +ownership, worktree deletion, or Linux/macOS support. In particular, the +relative file identifiers below are a scoped extension of ADR 0002; arbitrary +absolute filesystem paths remain invalid domain-operation inputs. + +## Context + +The daemon currently exposes registered project, session and worktree IDs, +but no file-listing or editor-save operation. S1 needs a real file tree and +editor on the canvas. S3 needs a reusable safe reader for explicitly selected +rule/skill files; a second filesystem service would create conflicting access +and overwrite policies. + +The user requested files/editor after worktree integration, including real +disk effects and restart behavior. A generic Tauri filesystem command, shell +command, or Rust type without a daemon handler would not fulfill that request. +Linux filenames are byte sequences and need not be UTF-8. A check using +`canonicalize` followed by an unrelated pathname `open` can follow a replaced +symlink. Atomic replacement prevents partial files, but portable Unix rename +does not provide compare-and-swap against an external editor's last write. + +## Decision + +### Ownership and initial scope + +Implement `file.list`, `file.read` and `file.write` in the daemon, backed by a +single internal `LocalFileService`. Core owns pure validated values and DTOs; +it performs no filesystem, hashing of open files, clock, or process I/O. +Storage resolves existing metadata IDs and remains the only SQLite owner. +No new table or migration is required for these three operations. + +Add each method to Rust wire, JSON catalog and TypeScript mirrors only with +its functional handler. The desktop continues to use `daemon_invoke`; there +is no `file_read` or other domain-specific Tauri command. Operations run in +bounded blocking tasks rather than on the asynchronous socket reader. + +`file.write` replaces an existing regular text file. It never creates a +missing file or parent directory, renames a user file, or deletes one. Those +remain required future file-management increments, tentatively under +`file.create`, `file.rename`, and `file.prepare_delete`/`file.delete`; they +are not advertised or implemented as stubs now. Creation will require +exclusive creation, rename a no-clobber/state policy, and deletion a reviewed +state-bound target. This sequencing does not remove M29's remaining scope. + +### Target and byte-exact path contracts + +Every request carries one registered target: + +```text +FileTarget = { kind: "project", projectId: UUIDv7 } + | { kind: "session", sessionId: UUIDv7 } + | { kind: "worktree", worktreeId: UUIDv7 } +FilePath = canonical padded base64 of repository-independent Unix path bytes +FileRevision = "v1:" + 64 lowercase hexadecimal SHA-256 characters +``` + +`FileTarget` has the same tagged representation as the existing Git target, +but file semantics belong to this service. A project resolves to its +registered selected directory, a session to its persisted cwd, and a worktree +to its managed root. The service must not expand a project subdirectory to +the repository root. Worktree-backed targets require an active association; +creating/orphaned/removal-pending targets are not writable. Reads may explain +recovery state, but must not substitute another directory silently. + +Roots are derived from daemon metadata and validated on every operation; +clients do not echo a root path back as authority. Root opens must reject a +persisted canonical root that now resolves elsewhere. Managed worktree +identity checks reuse the runtime worktree validation before granting access. + +`pathBase64` is limited to 4096 decoded bytes. Empty bytes represent the root +only for `file.list`. Nonempty paths are relative `/`-separated components; +reject leading/trailing `/`, empty components, NUL, `.` and `..`. Names with +spaces, Unicode, non-UTF-8 bytes, leading `-`, literal backslashes or colons +remain valid Unix names. There is no Windows-path normalization, Unicode +normalization, case folding or Git pathspec interpretation. The existing +`GitRelativePath` is deliberately not reused. + +Responses return `pathBase64` as the exact identifier and `displayName` for +presentation. Escape control/undecodable bytes in display text; never rebuild +an operation path from that text. Raw names never become HTML or command +arguments. Non-UTF-8 names are listable and can identify an editable UTF-8 +file where the underlying filesystem permits such names. APFS can reject +invalid UTF-8 filenames with EILSEQ before the service is called; Linux socket +tests exercise those names and pure contract tests cover their wire identity +on both platforms. + +### Public operations + +All payloads use camelCase, reject unknown fields and keep existing versioned +response envelopes. Optional fields are omitted when absent. + +| Method | Request | Successful response | +| --- | --- | --- | +| `file.list` | `{ target, pathBase64, limit?, afterNameBase64? }` | `{ entries: FileEntry[], nextAfterNameBase64?, observedAtMs }` | +| `file.read` | `{ target, pathBase64 }` | `{ pathBase64, text, revision, sizeBytes, modifiedAtMs?, observedAtMs }` | +| `file.write` | `{ target, pathBase64, text, expectedRevision }` | `{ pathBase64, revision, sizeBytes, modifiedAtMs?, writtenAtMs }` | + +`FileEntry` is `{ pathBase64, displayName, kind, sizeBytes?, modifiedAtMs? }`. +Kind is `file | directory | symlink | other`; it describes observed type, not +an access grant. Symlinks are visible but not traversed or edited. `other` +includes devices, sockets and FIFOs and cannot be opened through this API. + +List one directory, not a recursive tree. Default page size is 100, maximum +200. Order by raw name bytes; `afterNameBase64` is a validated single filename +and is an exclusive lexicographic cursor. Bound enumeration to 10,000 entries +and the encoded response to 512 KiB, returning a continuation cursor before +exceeding the response budget. A directory exceeding the enumeration limit +returns `file_listing_too_large`, never a success implying an empty tree. +Pages are observations, not a transaction snapshot: concurrent changes before +the cursor may require refresh. Search and persistent indexing are separate +increments. + +Read/write text is limited to **128 KiB of UTF-8 bytes**, including any BOM. +This bound allows worst-case JSON escaping inside the existing 1 MiB frame +with room for the envelope. Preserve bytes represented by the text exactly, +including CRLF, final newline and BOM; do not normalize automatically. +The service reads at most the limit plus one byte and rejects oversized +files, invalid UTF-8 or NUL-containing content. Binary/media previews need a +separate bounded byte/asset operation; this text slice does not claim them. +`file.list` can still show those files. The UI keeps its unsaved buffer after +every error. + +All timestamp fields are Unix epoch milliseconds. File modification time is +optional if it cannot be represented; absence is not zero. Revision checks +use higher-resolution metadata internally and never depend on epoch-ms alone. + +### Descriptor-relative filesystem access + +Use safe APIs from the existing direct daemon dependency `rustix 1.1.4` with +feature `fs`. Its local source provides `openat`, `statat`, `fstat`, `renameat`, +`unlinkat`, directory iteration and `fsync`. Traversal and publication introduce no project `unsafe` block, shell +command, external file utility or platform-specific path syntax. Darwin ACL +inspection has the narrowly isolated exception defined below. `renameat` is the portable directory-relative replacement operation. +[rustix reference](https://docs.rs/rustix/1.1.4/rustix/fs/fn.renameat.html) + +Open the validated root as a directory descriptor, then walk each path +component relative to its already-open parent with +`RDONLY | DIRECTORY | NOFOLLOW | CLOEXEC`. Open a leaf with +`RDONLY | NOFOLLOW | NONBLOCK | CLOEXEC`, then `fstat` that descriptor and +require a regular file before reading. `NONBLOCK` prevents a FIFO substituted +at the leaf from hanging the daemon. Use no-follow stat for listing entries; +never open an `other` entry as if it were ordinary text. + +Keep the parent descriptor through validation, temporary-file creation and +rename. Reopening an absolute concatenated pathname after validation is not +an acceptable fallback. Revalidate root/parent device+inode identity before +publication; if metadata registration or directory identity changed, abort +with `file_target_changed` and discard only the service-owned temporary file. +For managed worktrees, hold a short operation lease against worktree removal +for the final write transaction. File reads need no process lifecycle lock. +File I/O must not delay stop of an unrelated session behind enumeration. + +Descriptor-relative traversal prevents symlink substitution from redirecting +access. It does not promise that a directory descriptor's inode remains at +the same pathname if another same-user process renames that directory. The +service acts on the opened object and detects observed namespace changes; +it is not a security sandbox against another process with the same Unix UID. + +### Revision checks and atomic save + +Compute revision in the daemon over file bytes plus identity and change +metadata: device, inode, size, high-resolution mtime/ctime, link count and +permission mode. Encode fields canonically with a version prefix and hash +with SHA-256; the resulting `FileRevision` remains opaque to clients. Make +`sha2 0.10.9`, already resolved in the workspace lockfile, a direct daemon +dependency if used. The revision is content/identity-based, not a database +counter or process-local token, so unchanged files can be checked after a +daemon restart. It is not an authorization credential. + +For read, compare descriptor metadata before and after the bounded read; +return `file_conflict` if a concurrent change was observed. For write: + +1. Acquire a service mutation lock keyed by parent device/inode and raw leaf + name. It must serialize overlapping requests even when target IDs differ. +2. Open/inspect the current leaf by descriptor, reject nonregular files and + multiple hard links, read its bounded bytes, and require the caller's + `expectedRevision` to match the observed revision. +3. Create a unique sibling temporary file with `CREATE | EXCL | NOFOLLOW`, + initially mode 0600. Write the complete new text, apply the supported + permission metadata, flush and `fsync` the temporary descriptor. +4. Reopen/reinspect the current leaf through the same parent, verify identity + and revision again and revalidate the target/parent. On conflict leave the + original untouched and remove only that operation's temporary file. +5. Atomically `renameat` the temporary sibling onto the existing name, then + `fsync` the parent directory. Return a revision for the published inode. + +Ownership/group and ordinary permission bits must be preserved when saving; +if this cannot be done without elevated access, reject the write. The first +slice is not a full metadata-preserving file copier: ACLs, extended attributes, +resource forks and platform flags need explicit preservation or a documented +unsupported result before accepting such a save. They must not be silently +advertised as preserved. Hard-linked files return `file_metadata_unsupported` +because replace would otherwise break the link relationship. + +The initial macOS implementation preserves one explicitly supported extended +attribute, `com.apple.provenance`. Real files created on the validation host +receive this attribute automatically, including the service's temporary files. +Read its bounded value through the pinned descriptors, copy it when needed, +and verify that the source and destination attribute sets and bytes agree +before publication. If the source has no such attribute and the destination +does, reject the save unless exact absence can be established; do not assume +that a successful removal call proves absence. Reject every other extended +attribute, oversized value or inspection/copy mismatch. This is a narrow +preservation exception, not permission to silently discard unknown metadata. + +The lock guarantees revision ordering among daemon writers. External editors +do not honor it. The final comparison followed by POSIX `renameat` leaves a +small external-write race: it is **optimistic conflict detection**, not a +filesystem compare-and-swap or a guarantee of zero lost updates against an +uncooperative writer. This limitation must remain in editor/save documentation +and tests must not claim to prove its absence. A later stronger design may +add recoverable versions or platform primitives under another ADR. + +Failure before rename does not alter the destination. Failure after rename +is materially different: return `file_durability_uncertain` with +`writeApplied: true` and the observed revision when possible, instructing the +client to re-read before retrying. Do not pretend rollback occurred or send a +second implicit write. A daemon crash can leave a uniquely named temporary +file; startup must not sweep unknown files from project directories. + +### Narrow Darwin ACL metadata boundary + +The current safe `rustix` interface does not provide Darwin ACL inspection +through a file descriptor. The safe public exacl API takes a pathname, which +would lose the pinned-object guarantee; `/dev/fd` is not assumed equivalent. +A normal macOS text file must remain editable while a file with an extended +ACL must not lose that ACL during atomic replacement. + +Add `crates/file-metadata` solely for the safe public function +`has_extended_acl(fd: impl AsFd) -> io::Result`. Its private Darwin +module contains the minimal audited bindings to `acl_get_fd_np`, +`acl_get_entry` and `acl_free`. Constants and signatures are verified against +the installed Apple SDK. The borrowed descriptor is never closed, returned +ACL storage is owned by an RAII guard, errors preserve errno, and callers +never receive raw pointers. No subprocess or path reopening is permitted in +this service. Linux continues to inspect ACL/xattrs through safe rustix APIs. + +This crate explicitly denies unsafe code except within that private module, +and denies unsafe operations within unsafe functions unless individually +marked. It is the sole exception to inheriting the workspace's unsafe-code +forbid lint; workspace/core/daemon/session policy remains unchanged. All +other lints retain the workspace's strictness. The exception enables direct +inspection rather than weakening the file write policy or rejecting every +ordinary file on macOS. Test the public safe API with real regular files and +extended ACLs, and review ownership and each unsafe call before publication. + +### Errors, synchronization and S3 reuse + +Use existing `ApiError` envelopes. Stable service error codes are +`file_target_not_found`, `file_target_changed`, `file_not_found`, +`file_not_directory`, `file_not_regular`, `file_symlink_not_allowed`, +`file_permission_denied`, `file_too_large`, `file_not_text`, +`file_listing_too_large`, `file_conflict`, `file_metadata_unsupported`, +`file_durability_uncertain` and `file_io_error`. Invalid encoded path, text +limit or revision shape is `invalid_input` at the wire boundary. Conflict +details can carry `currentRevision`, never full old/new text or env values. +Map OS errors without logging file content, secrets or raw program commands. + +This first slice uses explicit response/re-read synchronization. Do not add a +`file.changed` catalog entry while the public socket only supports terminal +subscriptions and no implemented file event feed. S1 refreshes the saved file +and affected directory after mutation, re-reads on explicit refresh/refocus, +and re-reads after daemon reconnect. A later watcher/feed must provide a real +subscription contract, bounded events, gap/reconnect semantics and the same +epoch-ms timestamps. Polling/refresh is not described as live external edits. + +Expose descriptor-safe bounded read/resolution as internal service methods +for S3. The knowledge layer supplies an explicit allowlist and provenance for +rule/skill discovery; it does not gain arbitrary root access from a path string. +Global skill roots will require registered internal read-only capabilities +separate from project editor-write targets. Credential discovery/exposure is +not part of this increment. S3 must not add a second unsafe reader, database, +shell runner or Tauri client. + +### Acceptance + +Use real temporary directories, SQLite and the production daemon socket: + +- List and edit a file under each registered target, including a project + subdirectory and a session with `relativeDirectory`; observe disk bytes. +- Preserve a non-UTF-8 filename, spaces, leading `-` and literal backslash; + use only the returned identifier for follow-up requests. +- Reject absolute/traversal/NUL paths, symlink components and symlink leaves; + swapped FIFO/device leaves do not block the request or get read as text. +- Refuse a target/root replaced between requests or during observed traversal; + file operations and worktree removal cannot race through the final save. +- Two daemon clients reading one revision cannot both commit different writes; + changing bytes or inode externally before validation produces conflict. +- Reject binary/oversized files and unsupported metadata without changing the + destination. Preserve line endings, mode and complete Unicode text. +- Inject failures before rename and after rename/fsync; verify original bytes + or the explicit applied-but-uncertain outcome respectively. +- Reconnect/restart, read the saved text/revision again, and cover pagination + and encoded frame bounds. S3's reader uses the same safe implementation. +- Execute on Linux and macOS; compile/test wire mirrors. S1 separately proves + open → edit → save → conflict/reload on the real desktop. Types, mocked UI, + or this ADR alone do not mark M29/M30 complete. + +## Consequences + +The first editor slice has a small public surface and a single access policy +for S1/S3. Exact filename identity survives Unix/JSON boundaries. Existing +documents remain ordinary files, with no second copy silently made canonical +in SQLite. Directory and file limits are explicit and can be extended with +streaming/indexing later. + +Base64 identifiers and optimistic revisions add client code; symlinked and +unsupported-metadata files initially remain read-only/unavailable for save. +Atomic replacement changes inode identity. External-writer races and richer +metadata preservation remain explicit engineering limits. No file operation +spawns a process, changes session status, writes custom-agent env values to +logs, changes Git author configuration, or creates coauthor trailers. + +## Alternatives considered + +- Absolute string paths or generic Tauri fs access: rejected because clients + could bypass registered target identity and introduce a second I/O owner. +- UTF-8-only paths or Git pathspec reuse: rejected because valid Unix filenames + would become unaddressable or acquire unrelated Git argument restrictions. +- Canonicalize then normal open: rejected as the sole safety mechanism; + descriptor-relative no-follow traversal is available on both supported OSes. +- Truncate/write in place: rejected because interruption can destroy the + original and partial bytes are visible to agents/editors. +- Unconditional last-writer-wins: rejected because ordinary stale editors + would overwrite external work silently. Optimistic revision detects observed + conflicts while accurately describing the remaining portable rename race. +- Announce all future methods/events immediately: rejected because advertised + names without usable handlers or event delivery are not functionality. diff --git a/docs/codex/artifacts/xirp/knowledge-inspector-desktop.png b/docs/codex/artifacts/xirp/knowledge-inspector-desktop.png new file mode 100644 index 0000000..44c24af Binary files /dev/null and b/docs/codex/artifacts/xirp/knowledge-inspector-desktop.png differ diff --git a/docs/codex/artifacts/xirp/knowledge-inspector-narrow.png b/docs/codex/artifacts/xirp/knowledge-inspector-narrow.png new file mode 100644 index 0000000..a130703 Binary files /dev/null and b/docs/codex/artifacts/xirp/knowledge-inspector-narrow.png differ diff --git a/docs/codex/artifacts/xirp/organization-desktop.png b/docs/codex/artifacts/xirp/organization-desktop.png new file mode 100644 index 0000000..89c916d Binary files /dev/null and b/docs/codex/artifacts/xirp/organization-desktop.png differ diff --git a/docs/codex/artifacts/xirp/organization-narrow.png b/docs/codex/artifacts/xirp/organization-narrow.png new file mode 100644 index 0000000..1ba9ffa Binary files /dev/null and b/docs/codex/artifacts/xirp/organization-narrow.png differ diff --git a/docs/codex/maestri-runtime-report.md b/docs/codex/maestri-runtime-report.md index 5eec583..f5751f1 100644 --- a/docs/codex/maestri-runtime-report.md +++ b/docs/codex/maestri-runtime-report.md @@ -6,9 +6,9 @@ Baseline de criação: `0ac8dd7d49eefee16e5efbf389994f551bc584f5`, obtido de `origin/refactor/canvas-only-shell`. Integração final pertence à S1 nessa branch. O objetivo completo permanece na [matriz de runtime](../maestri-runtime-parity.md). -Esta entrega resolve o primeiro caminho backend. Floors, landing, editor, -canvas durável, presets, continuidade nativa, comunicação, rotinas e ambientes -remotos permanecem em desenvolvimento. Integração desktop não foi presumida. +A integração de worktrees está publicada e a primeira fatia de arquivos/editor +está publicada com testes locais aprovados. Floors, landing, canvas durável, presets, continuidade nativa, +comunicação, rotinas e ambientes remotos permanecem em desenvolvimento. Integração desktop não foi presumida. ## Commits disponíveis @@ -16,8 +16,13 @@ remotos permanecem em desenvolvimento. Integração desktop não foi presumida. | --- | --- | --- | | `7693468` | Saga separa preparação de início; associação SQLite transacional; subdiretórios; compensação e cancelamento de tokens. | Suite session/storage executada; após ajustes finais, 29 testes de create/prepare/remove passaram. Clippy session/storage sem warnings. | | `3c25a85` | Daemon liga new_worktree, snapshot/listagem, preparo/remoção e recovery à saga compartilhando SessionManager e Storage. | 118 testes core/daemon passaram, incluindo 9 fluxos novos pelo socket real; Clippy dos quatro pacotes sem warnings. | +| `910ed7d`, `1a74bbb` | Integra documentação e persistência knowledge da S3; dispatch bloqueante sai do leitor assíncrono. | Contratos core, 9 testes storage knowledge, 2 socket knowledge, 9 socket worktree e 15 testes IPC frontend passaram. | +| `b271825` | ADR 0006 define arquivos/editor, revisão e política de salvamento. | Decisão de arquitetura; não representa implementação funcional. | +| `45d817a` | Preserva o canvas e o navegador nativo publicados pela S1 até `1e75081`. | Typecheck frontend e testes daemon lib/IPC/knowledge/worktree passaram; CI e Packaging Linux/macOS aprovados. | +| `c98cf25` | Probe retenta ETXTBSY com o limite existente e preserva classificação/errno sem dados sensíveis. | 56 testes agents e Clippy passaram no macOS; duas regressões específicas de ETXTBSY aguardam Linux CI. | +| `1225931` | Serviço file.list/read/write, salvamento atômico com revisão, metadados preservados e cliente IPC tipado. | 13 contratos file, 8 casos internos de disco/falha, 12 socket file, 5 ACL macOS; 51 testes IPC frontend passaram. | -Ambos usam a identidade Git configurada `guicybercode`, sem trailers +Os commits usam a identidade Git configurada `guicybercode`, sem trailers `Co-authored-by`. Publicados em `origin/feat/maestri-runtime`. ## Contratos prontos para integração @@ -35,7 +40,8 @@ Ambos usam a identidade Git configurada `guicybercode`, sem trailers Rust wire, `protocol/catalog.json`, `ipc/methods.ts` e `ipc/domain.ts` foram sincronizados. Os dois últimos são artefatos aditivos: o baseline havia removido esses caminhos citados em AGENTS. Nenhum componente React, estado de -canvas, estilo ou bridge Tauri foi alterado. +canvas, estilo ou bridge Tauri foi alterado por S2; as mudanças S1 foram +preservadas na integração. O cliente IPC ganhou métodos tipados de arquivo. Erros relevantes: `worktree_confirmation_invalid`, `worktree_in_use`, `worktree_dirty`, `worktree_not_active`, `worktree_identity_changed`, @@ -49,9 +55,13 @@ ou reconexão. A existência dos nomes no catálogo não prova entrega de evento ## Verificação executada e limites PR de integração: [#44](https://github.com/guicybercode/Jig/pull/44), em draft. -CI e Packaging Linux/macOS iniciados; resultados ainda pendentes. +CI e Packaging de `45d817a` passaram em Linux/macOS, nos runs +[CI 34003098853](https://github.com/guicybercode/Jig/actions/runs/34003098853) e +[Packaging 34003098946](https://github.com/guicybercode/Jig/actions/runs/34003098946). +Os commits posteriores de probe/arquivos exigem novas execuções; a aprovação +anterior não comprova esses novos caminhos. -Host local: macOS. Passaram: +Host local: macOS. Na primeira integração de worktrees, passaram: - `CARGO_INCREMENTAL=0 cargo test -p cli-master-core -p cli-master-daemon --locked` — 118 testes, dos quais 9 novos em `worktree_ipc.rs`. @@ -64,8 +74,8 @@ Host local: macOS. Passaram: Os casos socket exercitam start/stop/restart concorrentes, troca de raiz por symlink entre create/start, token após edição externa, sessão sem vínculo usando o checkout, saída espontânea e restart com PID canário de outro manager. -A matriz CI Linux/macOS e o pacote desktop ainda exigem resultado externo; -não foram chamados de aprovados. O editor/canvas S1 ainda precisa consumir e +CI e Packaging da integração anterior estão aprovados conforme os runs +acima; o serviço novo de arquivos aguarda sua própria execução Linux/macOS. O editor/canvas S1 ainda precisa consumir e verificar estes contratos. Os IDs M14/M34/M35 continuam parciais no escopo total. ## Acordo com XIRP/S3 @@ -77,18 +87,86 @@ Resposta publicada em - **0004_knowledge_documents.sql reservada para S3**; worktrees não criam migração. - Acordados `knowledge.list/save/delete`, kind prompt/context, UUIDv7, escopo opcional de projeto, revisão inteira; update/delete exigem revisão, - conflito retorna `knowledge_conflict`. Ainda não são contratos publicados. + conflito retorna `knowledge_conflict`. Publicados em `1a74bbb`. - S3 pode publicar registro aditivo de lib.rs/migração/wire/mirrors/dispatch na própria branch junto de implementação/testes; S2 revisa e integra o commit. - `knowledge.updated` só pode ser anunciado como funcional com emissão real. -- S2 possui file service, workspace/floor/canvas e organização/workflow. - S3 possui composição de rascunhos/contexto; entrega usa SessionManager/adapters. +- S2 possui file service, workspace/floor/canvas e revisão dos contratos. + S3 possui módulos organização/workflow e composição de rascunhos/contexto; + entrega inicial usa SessionManager/adapters, sob responsabilidade S2. +- **0005_organization.sql reservada para S3**; workspace S2 usa 0006 depois + de integrar 0005. `organization.get/save` aprovados como proposta; não + anunciados no catálogo sem handlers e testes. Pin/archive nunca alteram PTY. +- `knowledge.discover/read` aprovados para a próxima entrega S3 com IDs opacos + de scan/entry, allowlist conhecida e raízes globais somente leitura. A fatia + inicial de arquivo fornece leitura segura sob alvos registrados; adaptar + capacidades globais requer integração explícita e ainda não foi concluído. + +## Arquivos/editor publicados + +`1225931` publica os três métodos sob alvos cadastrados: + +| Método | Request → response | +| --- | --- | +| `file.list` | `{target,pathBase64,limit?,afterNameBase64?}` → `{entries,nextAfterNameBase64?,observedAtMs}` | +| `file.read` | `{target,pathBase64}` → `{pathBase64,text,revision,sizeBytes,modifiedAtMs?,observedAtMs}` | +| `file.write` | `{target,pathBase64,text,expectedRevision}` → `{pathBase64,revision,sizeBytes,modifiedAtMs?,writtenAtMs}` | + +`target` identifica projeto, sessão ou worktree; a raiz é resolvida pelo daemon. +`pathBase64` preserva bytes Unix relativos, sem reconstruir caminhos a partir +de displayName. Texto é UTF-8 de até 128 KiB, sem NUL, com BOM/CRLF preservados. +A revisão opaca `v1:` é derivada de conteúdo e identidade/metadados. Listagem +pagina 100 por padrão/200 no máximo, enumera no máximo 10.000 nomes e limita +cada resposta a 512 KiB. O cliente expõe `listFiles`, `readFile`, `writeFile`. + +O save mantém proprietário/grupo/permissões e verifica metadados por descritor. +macOS preserva também `com.apple.provenance` com comparação exata; outros +xattrs, ACLs, flags e hardlinks não suportados são recusados. A exceção FFI +Darwin está isolada em file-metadata, revisada em SAFETY.md e coberta por 5 +casos reais. Workspace/core/daemon/session continuam com unsafe proibido. + +A publicação final usa a mesma proteção de mutação que a remoção de worktree +ou metadados do alvo. Duas gravações do daemon não confirmam a mesma revisão; +edições externas observadas geram `file_conflict`, preservando a versão em +disco. A comparação seguida de rename ainda é otimista diante de um editor +externo não cooperativo: não há CAS atômico portátil entre processos. +`file_durability_uncertain` com `writeApplied:true` exige reler antes de tentar +novamente. O cliente preserva esses metadados e não faz um segundo write. + +A execução local final incluiu 97 testes core, 68 daemon, 56 agents e 5 ACL, +mais 51 testes frontend IPC. Passaram typecheck, Clippy dos quatro pacotes, +Rustdoc com warnings negados, rustfmt e verificação de versões. Debug info +foi desativada somente nos comandos de validação para reduzir uso de disco. +APFS rejeitou a fixture de nome UTF-8 inválido com EILSEQ; os demais nomes Unix +foram exercitados pelo socket. Esse caso de bytes inválidos é obrigatório no +socket Linux e também é coberto pelos contratos puros. A nova CI Linux/macOS +ainda precisa confirmar este incremento; a evidência local é macOS. + +M29/M30 continuam parciais no produto: criação/movimentação/exclusão de arquivo, +watcher, integração de editor S1 e reader global S3 ainda não estão prontos. +O método interno compartilhável não comprova reúso já concluído pelo scanner. +Não há evento file.changed anunciado sem transporte. S1 deve manter o buffer +em erro, reler após conflito/reconexão e consumir o identificador retornado. ## Próxima etapa -Implementar serviço local `file.list/read/write` com alvos registrados, -leitura limitada, caminhos Unix preservados, escrita atômica e revisão de -conteúdo, documentando limites de concorrência externa. S3 reutiliza a leitura -segura. Em seguida, workspace/floor e canvas durável com revisão e importação -explícita do localStorage pela S1. A matriz mantém os incrementos posteriores; -esta ordem não reduz o objetivo aos serviços de arquivos. +Concluir operações de gerenciamento de arquivos conforme ADR 0006; depois, +workspace/floor e canvas durável com revisão e importação explícita do +localStorage pela S1. Organização pin/archive/workflow pertence à S3, com +migração 0005 reservada, antes da próxima migração S2. A matriz mantém presets, +continuidade, comunicação, rotinas e ambientes remotos como trabalho restante. + +## Reconciliação com S1 após arquivos + +A branch incorpora S1 até `dd84a49`, incluindo biblioteca/compositor de prompts, +refresh de worktrees e o tratamento de prazo de probe em `645047b`. Preserva-se +o diagnóstico saneado da S1 e o timeout explícito após esgotar o prazo; as +regressões reais de ETXTBSY da S2 permanecem no Linux. Dois testes AppShell +agora fornecem respostas explícitas de listWorktrees, inclusive a lista vazia +após remoção, em acordo com o novo refresh da S1. + +Após resolver os conflitos, passaram 97 testes core, 68 daemon e 63 agents; +Clippy dos quatro pacotes sem warnings. O check frontend completo passou: +278 testes, typecheck e build Vite. Essa execução não substitui o smoke nativo +nem a nova CI Linux/macOS. O serviço de arquivos e os controles S1 coexistem; +o editor de arquivos ainda precisa ser montado pela S1. diff --git a/docs/codex/proposals/pty-process-cache-ordering.md b/docs/codex/proposals/pty-process-cache-ordering.md new file mode 100644 index 0000000..f7a4e4a --- /dev/null +++ b/docs/codex/proposals/pty-process-cache-ordering.md @@ -0,0 +1,65 @@ +# PTY process snapshot ordering proposal + +Prepared for S2 review on 2026-09-05. The adjacent patch changes only +`crates/session/src/runtime/process_tree.rs`; it has **not** been applied to +the runtime. No session or process lifecycle test was weakened. + +Concurrent process scans can publish in completion order instead of observation +order. A late snapshot taken before a session started can permanently prune its +live root from `ProcessTree::known`. Subsequent fresh scans cannot reconstruct +ancestry after that identity has been discarded. A temporary harness reproduced +this against the unchanged source. The earlier manager-drop test left a live +orphaned shell, but its precise execution was not traced; attribution to this +race remains unconfirmed. + +The proposal records scan start and completion times. Cache publication never +replaces a later-started scan with an earlier-started one. Each process tree +accepts cached scans only if collection began after its previous applied scan +finished. The completion boundary also rejects overlapping scans, whose records +need not be atomic or observed in start order. Rejected cache entries trigger a +fresh scan using the remaining original timeout. Collection remains outside the +cache mutex; PID birth, ancestry, zombie and tracking-limit checks are retained. + +The strict boundary also rejects reusing the exact snapshot already applied +to that tree, so more refreshes may collect a new OS snapshot. Collection +complexity remains the existing process-snapshot cost; no new per-process scan +is added. S2 should assess cache hit rate and supervisor CPU with many sessions +as part of integration; accepting the same proven snapshot could be a separate +optimization with an unambiguous snapshot identity. + +## Validation + +The patch applied to a temporary copy with `patch -p1`; `git apply --check` +also passed against the current source. The actual runtime file was compared +byte-for-byte afterward and remained unchanged. Temporary sources and binaries +were removed. No PTY sessions or native agent processes were started for these +checks. + +On macOS, a standalone module included the patched file and linked the already +compiled `nix` and `parking_lot` dependencies from `target/debug/deps`: + +- `rustfmt --edition 2024`: passed. +- `rustc --edition=2024 --test -D warnings` and the resulting test binary: + **11 passed**, zero failures; seven new tests and four existing tests. +- `clippy-driver` with the same compile arguments, `-W clippy::all` and + `-W clippy::pedantic`: passed with warnings denied. +- A temporary mutation replacing the completion boundary with scan start + failed the overlap regression as expected: only one of two proven processes + remained. The mutation was discarded. + +New tests cover delayed pre-spawn cache publication, previously observed +descendants that escape/reparent, overlapping observations, monotonic cache +publication, current cached absence, forced lifecycle scans, and exhaustion of +the original timeout. Existing tests retain PID-reuse, zombie, ancestry, and +Linux stat-parser coverage. Cache tests use private local caches and synthetic +records, avoiding shared global-cache interference. + +This validates the module proposal, not full runtime integration. After S2 +applies it, run the session crate tests and lifecycle acceptance on Linux and +macOS, with bounded repetition of the previously failing manager-drop test. + +Source SHA-256 before applying: +`0d19016919fc39d0bf7ff121ecb5e92dd27bd15f6e839a51c8aa854e1e71d7f7`. + +Validated patched-source SHA-256: +`1b7a551a8c63f8f74e472b87f0e48c9efd17f9677e4ccde080c25b95ed0c89a0`. diff --git a/docs/codex/proposals/pty-process-cache-ordering.patch b/docs/codex/proposals/pty-process-cache-ordering.patch new file mode 100644 index 0000000..12b60c0 --- /dev/null +++ b/docs/codex/proposals/pty-process-cache-ordering.patch @@ -0,0 +1,324 @@ +--- a/crates/session/src/runtime/process_tree.rs ++++ b/crates/session/src/runtime/process_tree.rs +@@ -12,6 +12,7 @@ + + #[derive(Clone)] + struct CachedSnapshot { ++ scan_started_at: Instant, + captured_at: Instant, + records: Arc<[ProcessRecord]>, + } +@@ -40,6 +41,7 @@ + + pub(super) struct ProcessTree { + known: BTreeMap, ++ last_snapshot_completed_at: Instant, + scan_timeout: Duration, + max_tracked_processes: usize, + } +@@ -50,10 +52,11 @@ + scan_timeout: Duration, + max_tracked_processes: usize, + ) -> io::Result { +- let records = process_snapshot(scan_timeout, true)?; ++ let records = process_snapshot(scan_timeout, true, None)?; + let root_pid = root_pid.as_raw(); + let mut tree = Self { + known: BTreeMap::new(), ++ last_snapshot_completed_at: records.captured_at, + scan_timeout, + max_tracked_processes, + }; +@@ -69,12 +72,25 @@ + } + + pub fn refresh(&mut self) -> io::Result> { +- let snapshot = process_snapshot(self.scan_timeout, false)?; +- self.absorb(&snapshot.records) ++ let snapshot = process_snapshot( ++ self.scan_timeout, ++ false, ++ Some(self.last_snapshot_completed_at), ++ )?; ++ self.absorb_snapshot(&snapshot) + } + + pub fn refresh_fresh(&mut self) -> io::Result> { +- let snapshot = process_snapshot(self.scan_timeout, true)?; ++ let snapshot = process_snapshot(self.scan_timeout, true, None)?; ++ self.absorb_snapshot(&snapshot) ++ } ++ ++ fn absorb_snapshot(&mut self, snapshot: &CachedSnapshot) -> io::Result> { ++ // Scans are not atomic: even a later-started overlapping scan can ++ // contain older observations. Accept cached scans only when they began ++ // after the snapshot already absorbed here finished collecting records. ++ // Advance this bound before absorb, which may mutate then fail on limits. ++ self.last_snapshot_completed_at = snapshot.captured_at; + self.absorb(&snapshot.records) + } + +@@ -144,15 +160,30 @@ + fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { + Self { + known: BTreeMap::from([(root.identity.pid, root.identity)]), ++ last_snapshot_completed_at: Instant::now(), + scan_timeout: Duration::from_secs(1), + max_tracked_processes, + } + } + } + +-fn process_snapshot(timeout: Duration, force: bool) -> io::Result { ++fn process_snapshot( ++ timeout: Duration, ++ force: bool, ++ not_before: Option, ++) -> io::Result { ++ let cache = PROCESS_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); ++ process_snapshot_with(timeout, force, not_before, cache, scan_processes_uncached) ++} ++ ++fn process_snapshot_with( ++ timeout: Duration, ++ force: bool, ++ not_before: Option, ++ cache: &Mutex>, ++ scan: impl FnOnce(Duration) -> io::Result>, ++) -> io::Result { + let started = Instant::now(); +- let cache = PROCESS_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); + if !force { + let Some(cached) = cache.try_lock_for(timeout) else { + return Err(io::Error::new( +@@ -160,10 +191,11 @@ + "process-tree snapshot cache lock exceeded its deadline", + )); + }; +- if let Some(snapshot) = cached.as_ref() { +- if snapshot.captured_at.elapsed() <= SNAPSHOT_CACHE_TTL { +- return Ok(snapshot.clone()); +- } ++ if let Some(snapshot) = cached.as_ref().filter(|snapshot| { ++ snapshot.captured_at.elapsed() <= SNAPSHOT_CACHE_TTL ++ && not_before.is_none_or(|minimum| snapshot.scan_started_at >= minimum) ++ }) { ++ return Ok(snapshot.clone()); + } + } + let remaining = timeout.saturating_sub(started.elapsed()); +@@ -173,8 +205,10 @@ + "process-tree snapshot exceeded its deadline", + )); + } +- let records = Arc::from(scan_processes_uncached(remaining)?); ++ let scan_started_at = Instant::now(); ++ let records = Arc::from(scan(remaining)?); + let snapshot = CachedSnapshot { ++ scan_started_at, + captured_at: Instant::now(), + records, + }; +@@ -184,9 +218,20 @@ + // itself is already complete and identity-checked. + let remaining = timeout.saturating_sub(started.elapsed()); + if let Some(mut cache) = cache.try_lock_for(remaining) { ++ publish_snapshot(&mut cache, &snapshot); ++ } ++ Ok(snapshot) ++} ++ ++fn publish_snapshot(cache: &mut Option, snapshot: &CachedSnapshot) { ++ // Concurrent scans can finish in a different order than they started. ++ // Completion time is useful for TTL, but cannot order their observations. ++ if cache ++ .as_ref() ++ .is_none_or(|current| snapshot.scan_started_at >= current.scan_started_at) ++ { + *cache = Some(snapshot.clone()); + } +- Ok(snapshot) + } + + #[cfg(target_os = "linux")] +@@ -365,6 +410,180 @@ + } + } + ++ fn snapshot(scan_started_at: Instant, records: Vec) -> CachedSnapshot { ++ CachedSnapshot { ++ scan_started_at, ++ captured_at: Instant::now(), ++ records: Arc::from(records), ++ } ++ } ++ ++ #[test] ++ fn delayed_pre_spawn_cache_cannot_prune_a_new_live_root() { ++ let root = record(100, 1, 100, "root"); ++ let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); ++ let older = tree ++ .last_snapshot_completed_at ++ .checked_sub(Duration::from_millis(1)) ++ .unwrap(); ++ let cache = Mutex::new(Some(snapshot(older, Vec::new()))); ++ let refreshed = process_snapshot_with( ++ tree.scan_timeout, ++ false, ++ Some(tree.last_snapshot_completed_at), ++ &cache, ++ |_| { ++ assert!( ++ cache.try_lock().is_some(), ++ "scan must not hold the cache lock" ++ ); ++ Ok(vec![root]) ++ }, ++ ) ++ .expect("an old cached scan should cause a fresh scan"); ++ ++ let tracked = tree.absorb_snapshot(&refreshed).unwrap(); ++ ++ assert_eq!(tracked.len(), 1); ++ assert_eq!(tracked[0].pid, Pid::from_raw(100)); ++ assert!(tree.known.contains_key(&100)); ++ } ++ ++ #[test] ++ fn cached_snapshot_before_a_new_descendant_does_not_forget_its_identity() { ++ let root = record(100, 1, 100, "root"); ++ let child = record(101, 100, 101, "child"); ++ let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); ++ let now = Instant::now(); ++ let initial = snapshot( ++ now.checked_sub(Duration::from_millis(2)).unwrap(), ++ vec![root.clone()], ++ ); ++ let observed = snapshot( ++ now.checked_sub(Duration::from_millis(1)).unwrap(), ++ vec![root.clone(), child.clone()], ++ ); ++ let observed_completed_at = observed.captured_at; ++ tree.absorb_snapshot(&observed).unwrap(); ++ assert_eq!(tree.last_snapshot_completed_at, observed_completed_at); ++ let cache = Mutex::new(Some(initial)); ++ // The descendant has since escaped and been reparented. Losing its ++ // previously proven birth identity would make recovery impossible. ++ let escaped = record(101, 1, 101, "child"); ++ let refreshed = process_snapshot_with( ++ tree.scan_timeout, ++ false, ++ Some(tree.last_snapshot_completed_at), ++ &cache, ++ |_| Ok(vec![root, escaped]), ++ ) ++ .unwrap(); ++ ++ let tracked = tree.absorb_snapshot(&refreshed).unwrap(); ++ ++ assert_eq!(tracked.len(), 2); ++ assert!(tree.known.contains_key(&child.identity.pid)); ++ } ++ ++ #[test] ++ fn overlapping_later_started_cache_cannot_forget_an_observed_descendant() { ++ let root = record(100, 1, 100, "root"); ++ let child = record(101, 100, 101, "child"); ++ let now = Instant::now(); ++ let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); ++ let observed = CachedSnapshot { ++ scan_started_at: now.checked_sub(Duration::from_millis(2)).unwrap(), ++ captured_at: now, ++ records: Arc::from(vec![root.clone(), child]), ++ }; ++ tree.absorb_snapshot(&observed).unwrap(); ++ // B started after A, but read the process list before A observed this ++ // child. B finished late; completion and start order alone are unsafe. ++ let overlapping = snapshot( ++ now.checked_sub(Duration::from_millis(1)).unwrap(), ++ vec![root.clone()], ++ ); ++ let cache = Mutex::new(Some(overlapping)); ++ let refreshed = process_snapshot_with( ++ tree.scan_timeout, ++ false, ++ Some(tree.last_snapshot_completed_at), ++ &cache, ++ |_| Ok(vec![root, record(101, 1, 101, "child")]), ++ ) ++ .unwrap(); ++ ++ let tracked = tree.absorb_snapshot(&refreshed).unwrap(); ++ ++ assert_eq!(tracked.len(), 2); ++ assert!(tree.known.contains_key(&101)); ++ } ++ ++ #[test] ++ fn late_publication_cannot_replace_a_scan_started_later() { ++ let now = Instant::now(); ++ let newer = snapshot(now, vec![record(100, 1, 100, "root")]); ++ let older = snapshot( ++ now.checked_sub(Duration::from_millis(1)).unwrap(), ++ Vec::new(), ++ ); ++ let mut cache = Some(newer); ++ ++ publish_snapshot(&mut cache, &older); ++ ++ let retained = cache.unwrap(); ++ assert_eq!(retained.scan_started_at, now); ++ assert_eq!(retained.records.len(), 1); ++ } ++ ++ #[test] ++ fn current_cached_absence_still_prunes_a_terminated_identity() { ++ let root = record(100, 1, 100, "root"); ++ let mut tree = ProcessTree::with_root_for_test(root, 8); ++ let cache = Mutex::new(Some(snapshot(Instant::now(), Vec::new()))); ++ let refreshed = process_snapshot_with( ++ tree.scan_timeout, ++ false, ++ Some(tree.last_snapshot_completed_at), ++ &cache, ++ |_| panic!("a current cached snapshot should not need another scan"), ++ ) ++ .unwrap(); ++ ++ assert!(tree.absorb_snapshot(&refreshed).unwrap().is_empty()); ++ assert!(tree.known.is_empty()); ++ } ++ ++ #[test] ++ fn forced_lifecycle_scan_bypasses_an_eligible_cached_snapshot() { ++ let now = Instant::now(); ++ let cache = Mutex::new(Some(snapshot(now, Vec::new()))); ++ let refreshed = ++ process_snapshot_with(Duration::from_secs(1), true, Some(now), &cache, |_| { ++ Ok(vec![record(100, 1, 100, "root")]) ++ }) ++ .unwrap(); ++ ++ assert_eq!(refreshed.records.len(), 1); ++ } ++ ++ #[test] ++ fn rejected_cache_does_not_restart_an_exhausted_scan_budget() { ++ let now = Instant::now(); ++ let cache = Mutex::new(Some(snapshot( ++ now.checked_sub(Duration::from_millis(1)).unwrap(), ++ Vec::new(), ++ ))); ++ let result = process_snapshot_with(Duration::ZERO, false, Some(now), &cache, |_| { ++ panic!("an exhausted original budget must not start a scan") ++ }); ++ ++ let Err(error) = result else { ++ panic!("an old cache cannot satisfy an exhausted request"); ++ }; ++ assert_eq!(error.kind(), io::ErrorKind::TimedOut); ++ } ++ + #[test] + fn only_proven_descendants_are_retained_across_group_changes() { + let root = record(100, 1, 100, "root"); diff --git a/docs/codex/xirp-context-report.md b/docs/codex/xirp-context-report.md index 9a3bca7..b26fe51 100644 --- a/docs/codex/xirp-context-report.md +++ b/docs/codex/xirp-context-report.md @@ -10,11 +10,14 @@ The full user request remains open. Source/acceptance inventory: ## Coordination and ownership -S2 approved the first knowledge contract and additive shared registrations in -[xirp-coordination-reply.md](xirp-coordination-reply.md), reserving migration -`0004_knowledge_documents.sql`. S2 retains organization/workflow, runtime, -worktrees, file service and conversation capabilities. S1 retains canvas and -composer integration; the request is in [xirp-integration-request.md](xirp-integration-request.md). +S2 approved the knowledge, discovery and organization contracts and additive +shared registrations in [xirp-coordination-reply.md](xirp-coordination-reply.md), +reserving migrations `0004_knowledge_documents.sql` and `0005_organization.sql` +for S3. The later organization acknowledgment delegates those modules to S3 +and supersedes S2's initial retention of pin/archive/workflow implementation. +S2 retains shared-contract review, runtime, worktrees, file service and +conversation capabilities. S1 retains canvas and composer integration; the +request is in [xirp-integration-request.md](xirp-integration-request.md). The active daemon dispatch is `server.rs`. Knowledge handlers reuse its existing storage connection and generic request path, with no session/process @@ -49,7 +52,7 @@ case folding; Unicode text is preserved but non-ASCII case folding is not claimed. Pagination is per-request consistent, not a multi-request snapshot; refresh observes inserts/edits made during browsing. -## Verification executed on macOS +## First-increment verification executed on macOS Use `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0` for the Rust commands below; debug information was reduced because the host @@ -80,17 +83,199 @@ had limited free disk space. Direct typed serde duplicate-field validation is not a promise that duplicate keys are rejected after the daemon's intermediate JSON Value decode. -Linux CI, actual Tauri/canvas consumption and full parity remain unverified. +Draft [PR 45](https://github.com/guicybercode/Jig/pull/45) targets the canvas +branch to run Linux/macOS CI. Actual Tauri/canvas consumption and full parity +remain unverified; CI state must be checked rather than inferred from push. -## Commits / next integration +## First-increment commits and integration checkpoint - `569449f`: source inventory and integration proposal; pushed. - `2f3b7c8`: persisted knowledge contract/SQLite/daemon/typed IPC; pushed. -- Isolated `KnowledgePanel` is implemented as the next UI commit. Props: +- `a757f05`: isolated `KnowledgePanel`, 12 behavior tests and visual evidence; pushed. Props: `{client,currentProject,onInsert,insertDisabledReason}`; insertion receives `{sourceId,kind,title,body}` and must append to an editable composer draft. Keep panel mounted if unsaved editor drafts must survive closing its surface. -- Next: bounded rules/skills discovery; consume S2 pin/archive/workflow and - initial-objective contracts; mount with S1; verify additional agent features - only against trustworthy native sources. Portal/MCP remain explicit separate - dependencies; no connection is simulated. +- Discovery and organization subsequently advanced as recorded below. + Initial-objective delivery remains an S2 runtime dependency. Canvas acceptance + and additional agent features require their own integration evidence. + Portal/MCP remain separate dependencies; no connection is simulated. + + +## Canvas integration and revision provenance checkpoint + +Published `cfc79ba` merges S1 canvas through `6f6af26`, preserving both owners' +coordination notes. The merged frontend check passed TypeScript, all 137 tests +and production build without the earlier NODE_OPTIONS workaround; all 39 daemon +tests passed. S1 subsequently began the per-terminal prompt composer and will +mount the library while retaining hidden editor drafts. + +Published `b7b0b4e` adds sourceRevision to KnowledgeInsertion. SourceId/revision +identify the editor's base snapshot; title/body are the actual draft and can +include unsaved edits. Both provenance fields are null for a never-saved draft. +Fourteen library tests, typecheck and targeted ESLint passed. Insertion remains +explicit draft composition; S1 owns delivery through its terminal input queue. + +For b7b0b4e, macOS Quality and Packaging passed. Linux Quality failed in the +existing adapter catalog test `detect_and_set_enabled_do_not_require_real_clis` +with a version-probe spawn failure; frontend, Playwright and clippy passed first. +The runtime owner received the exact log and is investigating. Linux acceptance +is not complete: https://github.com/guicybercode/Jig/actions/runs/34002926329 + +The concrete next organization contract is +[xirp-organization-contract.md](xirp-organization-contract.md). S1 supports S3's +bounded modules; S2 has now delegated their implementation and allocated +0005_organization.sql. S3 owns those modules, with S2 retaining runtime and +initial-objective execution. Implementation continues toward the original +objective. + + +## Rule/skill discovery increment + +Backend and typed IPC published as `4a367d0`. Approved knowledge.discover/read +inventory known root/global/admin rule and skill locations. They accept registered +project IDs and opaque expiring selectors, never caller paths. Reads revalidate +file/directory identity and nanosecond mtime/ctime, stay <=64 KiB and never execute +content. Skill-directory links bind the captured target. See the precise bounds, +source policy and remaining nested-scope gaps in +[xirp-discovery-contract.md](xirp-discovery-contract.md). + +The isolated KnowledgeSourceInspector consumes discoverKnowledge/readKnowledge +from the existing IpcClient. Props are client, optional currentProject and +connectionKey. S1 should pass daemon instance/reconnect generation. Lists show +path/provider/scope/precedence, linked origin, availability and incomplete-scan +issues. Source previews are plain text. Correlation and full source-descriptor +checks prevent displaying another source's content; request epochs discard stale +responses. It neither edits sources nor sends terminal input. + +Verification on macOS: full core/daemon 146 tests passed; a subsequent independent +QA review found an enumeration-budget edge case that discarded already collected +candidates. It was fixed with a real-directory regression; the final 21 knowledge +daemon tests passed, including both socket tests. Final core/daemon all-target +clippy and formatting passed. Full frontend check passed TypeScript, 162 tests +and Vite build. Targeted inspector/IPC/library 43 tests and ESLint passed. + +Chromium screenshots use an isolated mock fixture at 1100px and 375px, not a claim +of canvas integration: [desktop](artifacts/xirp/knowledge-inspector-desktop.png), +[narrow preview with keyboard focus](artifacts/xirp/knowledge-inspector-narrow.png). +Both had no horizontal viewport overflow. Temporary preview files/server removed. +S1 mounting, real desktop smoke and this new increment's Linux/macOS CI remain +separate integration evidence. + + +## Organization increment + +Backend+typed IPC published as `fce3982`. Migration 0005 adds project/session +organization tables with cascading metadata FKs. organization.get reads up to +100 distinct registered targets in request order, without persisting defaults. +organization.save compares the whole record's revision and atomically saves +pin/archive flags plus session workflow. Revision 0 means unwritten defaults; +returning to default flags after a save retains the row and positive revision. + +OrganizationPanel receives client(getOrganization/saveOrganization), optional +currentProject{id,name}, currentSession{id,name,status}, connectionKey and +onChanged(entry). It preserves per-target drafts and requires explicit Save. +Conflicts preserve choices; Refresh revision displays the latest saved values +before another save. Current daemon status remains visible even when workflow +is Done or archive is selected. S1 should keep the panel mounted while hidden +and refresh canvas sorting/filtering through onChanged. + +Verification: all 199 core/storage/daemon tests passed, including 10 real SQLite +organization tests and 2 socket tests. The socket acceptance starts /bin/cat +through SessionManager, changes archive/pin and every workflow value, and +checks the live session DTO stays unchanged. Restart preserves organization; +explicit stop/delete remain separate actions and repository files survive. +Core/storage/daemon all-target clippy, formatting and whitespace passed. +Independent QA found no concrete backend or UI correctness defect; the batch +snapshot stress test remains probabilistic, while production reads explicitly +share one transaction. + +Frontend focused 26 tests (panel 9, decoder 6, client 11), typecheck and targeted +ESLint passed. Isolated Chromium fixtures at 1100px/375px showed visible keyboard +focus and no horizontal overflow: [desktop](artifacts/xirp/organization-desktop.png), +[narrow](artifacts/xirp/organization-narrow.png). Temporary preview files/server +removed. Real canvas mounting/filtering and Linux/macOS CI remain integration +work; these screenshots are not native desktop acceptance. + +Native initial-objective/options/continuity evidence is recorded in +[xirp-native-capability-evidence.md](xirp-native-capability-evidence.md) and sent +to S2. Installed flags do not prove successful resume/fork; actual adapter/start +wiring and trustworthy conversation IDs remain required. + +## Canvas reconciliation checkpoint — 2026-09-05 + +Reconciled S1 through `fd7f8d5` (including real library/composer mounting in +`1150de6`), retaining source revisions, discovery, organization migration0005, +worktree.list and native browser/runtime changes. The three catalogs match +36 method names. No unresolved merge markers remain. + +Combined macOS validation: frontend293 tests, TypeScript/build and12 Chromium +E2E pass. Rust core/agents/daemon tests passed through their complete binaries; +storage45 tests passed separately, and all-target Clippy for core/storage/ +agents/session/daemon plus formatting passed. The expanded session PTY suite +had one failure: `dropping_the_manager_cleans_a_live_process_group`; its exact +rerun passed, but the failed run left a live test shell. Runtime/process-tree +code was unchanged by this merge. A cache-publication race is under read-only +review and was reported to S2; this checkpoint does not claim a clean full +backend suite or native/Linux acceptance. No test assertion was weakened. + +Canvas library insertion now has real host consumption as an explicit editable +text snapshot; it does not implicitly send/start or retain a live source link. +Discovery inspector and organization mounting/filtering remain S1 follow-ups. + +## Cross-platform reconciliation evidence + +At published `41f679c`, [CI Linux/macOS](https://github.com/guicybercode/Jig/actions/runs/34005300527) +and [Packaging Linux/macOS](https://github.com/guicybercode/Jig/actions/runs/34005300530) +passed. Both CI jobs ran frontend checks, Chromium E2E, formatting, Clippy, +workspace Rust tests and documentation. This covers the already published +knowledge/discovery/organization implementation and the reconciled composer. +It does not cover subsequent uncommitted nested discovery, native interactive +acceptance, or prove the independently reproduced process-cache race absent. +S1's inspector mounting is separately published in `9329d01`; its merge into +this branch follows the isolated nested scanner increment. + +## Nested project source discovery + +The existing scanner now inventories supported rule/skill locations in the +registered project's descendants. Root sources precede globals/admin sources, +which precede nested traversal. Relative scope identifies each configuration +owner, including names containing spaces. Ordinary directory links are skipped; +known skill links retain the existing captured-target semantics. Exact provider, +VCS and dependency pruning is visible, and all phases share response, entry, +node and cumulative directory-depth limits. No IPC fields, methods, migration, +Git operation or file-service adapter changed. + +Validation on macOS: all **31 knowledge daemon tests** pass, including **9 new +scanner regressions and 1 new real-socket regression**. Tests cover nested +formats/provider identity, exact pruning, root/global priority, cumulative +depth, shared enumeration limits, pinned-directory replacement races and old +capability rejection after replacing a nested parent. Existing safe-reader and +scan-expiry tests remain green. All-target daemon Clippy, formatting and diff +checks pass. Independent review found no remaining concrete defect. + +Sources above the registered project and effective session-cwd ancestry remain +unevaluated. Native activation, editing and bundled/plugin sources remain open +as described in the discovery contract. Earlier cross-platform results at +`41f679c` are not evidence for this new scanner increment; its CI follows push. + +## Integrated source inspector + +Nested source discovery is published in `8f5ff86`. Merge `a76edd7` reconciles +S1 through `35558c2`, including inspector mounting `9329d01`; source inspection +is now available alongside the saved library in the canvas. The existing +source component and decoder already render/search project-relative scopes. + +Combined frontend validation on macOS: **300 tests across 30 files**, TypeScript +and production build pass. **13 Chromium E2E** pass, including the inspector +at 360 px, keyboard focus and retained saved-library edits while switching tabs. +The inspector E2E runs with the daemon disconnected; real source reads remain +covered by the daemon/socket tests. These layers do not establish packaged +WebKit or native CLI activation acceptance. + +The runtime investigation now has a concrete S2 review artifact: +[process-cache ordering proposal](proposals/pty-process-cache-ordering.md) and +[patch](proposals/pty-process-cache-ordering.patch). The patched module passes +11 standalone tests (7 new), formatting and Clippy on macOS; the unchanged +runtime still awaits S2 application and full lifecycle validation. The proposal +retains identity checks and deadlines, orders cache publication and rejects +overlapping older observations. No runtime file changed in this handoff. diff --git a/docs/codex/xirp-coordination-reply.md b/docs/codex/xirp-coordination-reply.md index 9d27ceb..c2fd5d8 100644 --- a/docs/codex/xirp-coordination-reply.md +++ b/docs/codex/xirp-coordination-reply.md @@ -42,3 +42,76 @@ entries by name, never replace either side's whole catalog. Integration evidence and published commits will be kept in `docs/codex/maestri-runtime-report.md` on the S2 branch. S2 will inspect S3's `docs/codex/xirp-context-report.md` and this request thread for follow-ups. + +## Second increment acknowledgment — 2026-09-05 + +S2 reviewed `xirp-discovery-contract.md` and approves additive +`knowledge.discover` / `knowledge.read` with the proposed opaque scan/entry +IDs, bounds, expiry and specific safe errors. S3 may register the names and +shared exports on its branch together with functional handlers/socket tests, +following the first increment workflow. No migration is needed. Keep source +provenance distinct from proof that a CLI loaded a rule or skill. + +Known skill-directory symlinks may be resolved once to a pinned directory +capability; do not follow leaf symlinks or reopen an unchecked absolute path. +The global root capabilities remain read-only. No generic file editor or +arbitrary path request is authorized through knowledge methods. + +S2 has integrated S3 commits as `910ed7d` (docs) and `1a74bbb` (runtime). +The combined core/storage/socket tests and 15 IPC frontend tests passed. +Knowledge dispatch now runs in spawn_blocking; keep that behavior in future +merges. Worktree and knowledge entries coexist in both TypeScript mirrors. + +File contracts are defined in S2 ADR **0006-local-files-and-editor.md**; +implementation is underway in daemon::files with descriptor-safe internal +read access. Expose a narrow adapter when ready rather than merging policies +for global rule discovery and project editor write targets. + +General metadata broadcasting remains a required future S2 increment. No +working transport is available to register knowledge.updated/file.changed +yet; continue explicit refresh and revision-conflict handling. Initial +objective execution also remains S2 work; host-owned editable drafts can be +built now with S1, without hidden sending. + +## Organization ownership and migration allocation — 2026-09-05 + +S2 reviewed `xirp-organization-contract.md` and agrees that S3 owns the +isolated organization modules (core/storage/daemon) and UI, consistent with +the original user/S1 split. This supersedes the first reply's retention of +pin/archive/workflow implementation. S2 retains shared-contract review, +workspace/floor/canvas persistence and runtime/initial-objective execution. +No duplicate organization implementation is underway in S2. + +Reserve **0005_organization.sql** for S3. File list/read/write needs no +migration; S2 workspace persistence begins with 0006 after integrating your +0005, without migration gaps. Approve `organization.get` and +`organization.save` with the proposed bounded distinct-target batch, +revision-zero defaults, whole-record optimistic save, FK tables and separate +workflow. S3 may add shared registrations on its branch with real handlers +and SQLite/socket tests, then publish a commit for S2 review. Maintain +spawn_blocking for blocking storage work. No metadata event is advertised +before the transport exists. Archiving must leave process execution intact. + +S2 has merged S1 through `1e75081` and pushed `45d817a`; frontend work remains +preserved. S2 will inspect the Linux catalog probe failure and coordinate a +narrow fix with evidence. The local file editor is now being compiled and +validated before its next push; its contract remains ADR 0006. + +## S2 file service published and reconciled — 2026-09-05 + +S2 pushed file service `1225931`, documentation `2499b0b`, and reconciled S1 +through dd84a49 in merge **968b393**, on origin/feat/maestri-runtime (PR44). +`file.list/read/write` have real socket handlers plus IpcClient +listFiles/readFile/writeFile and Rust/JSON/TS mirrors. ADR0006 and the runtime +report give exact byte-base64 paths, target IDs, revisions and limits. +The existing read adapter is internal to daemon::files; global S3 capabilities +still need an explicit adapter before claiming scanner reuse. No file event +is advertised; reader/editor refresh remains explicit. + +The merge retains S1's stronger probe deadline handling from645047b and +sanitized diagnostics, alongside S2's Linux ETXTBSY regressions. I adjusted +only two AppShell test fixtures to answer S1's new listWorktrees refresh +(including an empty list after removal), preserving UI implementation. +After merge, 97 core/68daemon/63agents tests and all278frontend tests passed, +as did Clippy/typecheck/build. New head CI Linux/macOS is pending; preceding +45d817a passed both CI and Packaging matrices. No coauthor trailers. diff --git a/docs/codex/xirp-discovery-contract.md b/docs/codex/xirp-discovery-contract.md new file mode 100644 index 0000000..faefb57 --- /dev/null +++ b/docs/codex/xirp-discovery-contract.md @@ -0,0 +1,82 @@ +# S3 rule/skill discovery contract + +Approved by S2 in `xirp-coordination-reply.md` after first knowledge commit +`2f3b7c8` and UI commit `a757f05`. +Approved narrow filesystem reader remains inside `daemon::knowledge`; no +migration or generic editor is introduced. Source inventory is +[xirp-rule-sources.md](xirp-rule-sources.md). + +Methods: + +- `knowledge.discover {projectId?: UUID}` inventories known global locations + and optionally one registered project, returning `{scanId,entries,truncated, + issues}`. `scanId` and per-entry IDs are ephemeral UUIDv7 capabilities owned + by the daemon. Entries show rule/skill, provider, origin/scope, source path, + name, precedence explanation and availability; being discovered does not + mean the native CLI loaded the item. +- `knowledge.read {scanId,entryId}` returns the source descriptor and bounded + UTF-8 content. Client paths are never passed to file opening. Expired scans, + changed files, nonregular/symlinked leaves and unavailable content return + specific safe errors; refresh obtains a new scan. + +Inventory limits: four scans cached for five minutes, 512 candidates, 10,000 +enumerated filesystem entries, depth 16. Sources at the selected project root +are inventoried first, then global/admin sources, then project descendants. +This keeps a large descendant tree from starving known global roots; all phases +share the same bounds. Fixed allowlisted metadata probes add bounded work per +visited project directory; the entry budget is not a literal syscall count. Entry JSON is capped at 448 KiB; at most 32 issues use up to 32 KiB. Read limit: 64 KiB; JSON envelope remains +under 1 MiB even for worst-case escaping. Truncation/issues must remain visible. +No content enters logs, Debug, error details, SQLite or telemetry. + +Symlink support is intentional at known skill-directory boundaries. Capture +the target and open it component by component using directory descriptors; +subsequent SKILL.md reads are pinned to that directory, using NOFOLLOW and +NONBLOCK before verifying regular-file identity. Merely canonicalizing and +then reopening a string path is insufficient. Rule/SKILL leaf symlinks are +reported as unavailable rather than followed into arbitrary credentials. + +Root locations come from daemon-owned HOME/CODEX_HOME and registered projects. +Their directory aliases are resolved using descriptors during capture, then +reads reopen the captured resolved path with NOFOLLOW and compare directory +and file identities. The viaSymlink flag identifies skill-directory links, +not OS root aliases such as macOS /var. + +A skill-directory link retargeted after discovery does not redirect an existing +capability: that scan still reads its captured original target if unchanged. +A fresh scan follows the new target. Source replacement/content edits conflict; +daemon restart, cache eviction and five-minute expiry invalidate scans. +Removing the registered project invalidates reads from its scan. + +The project inventory includes supported configuration locations in nested +project directories. `scopeDirectory` is relative to the registered project: +`.` identifies its root and `packages/api` identifies a nested configuration +owner. Grouped rules and skills retain their owning directory's scope. Paths +are display metadata; reads still require the existing opaque capabilities. + +General project traversal opens children relative to pinned directory +descriptors and never follows ordinary directory symlinks. Exact directory +names `.git`, `node_modules`, `vendor`, `.venv` and `venv` are pruned, as are +`.agents`, `.claude`, `.cursor` and `.codex`. Allowlisted rules/skills in the +first three already have dedicated walkers; `.codex` is not a documented +project-scoped source location. Explicit global CODEX_HOME sources still work. Pruning does not exclude an explicitly registered project +root with one of those names. Build directories with other names and nested +repositories remain inside the bounded inventory; `.gitignore` is not parsed. +The pruning policy and scan limitations are visible as issues. + +The depth limit includes the project-relative path and provider configuration +directories; entering a rule/skill root does not restart that budget. Sources +above the registered project, effective session-cwd ancestry, imports, +activation conditions, native overrides and bundled/plugin sources remain +unevaluated. Inventory does not prove which CLI loaded a source. + +No native credential/config files, support scripts, imports, or transcript +files are read. Editing waits for S2's common file contract. Root will publish +wire/catalog/mirror registration together with functioning handlers/tests. +All five knowledge operations run through spawn_blocking, preserving the S2 +composition change and existing generic transport. + +S1: isolated `KnowledgeSourceInspector` receives client, optional currentProject +and optional connectionKey (daemon instance or reconnect generation), with +refresh/list/preview. Pass the connection key to invalidate scans on reconnect. Keep it inside the canvas surface. +S1 has mounted the saved library and an editable prompt composer in `1150de6`; +source inspection is a separate read-only panel and never inserts or sends text. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md index 24c1dc7..1288da5 100644 --- a/docs/codex/xirp-integration-request.md +++ b/docs/codex/xirp-integration-request.md @@ -22,6 +22,28 @@ Will deliver isolated components with typed callbacks for saved prompts/context ## Responses +### S3 acknowledgment and implementation details + +S2 acknowledgment received in xirp-coordination-reply.md. First implementation uses +knowledge.list/save/delete; list accepts optional projectId/kind/query/cursor, +returns {entries,nextCursor}; cursor is exclusive UUIDv7 ID ascending, literal +title/body search. Pages cap at 50 rows and 512 KiB serialized entries. Title +256 UTF-8 bytes, body 64 KiB. Prompt/context errors do not attach serde causes. +Shared additive TypeScript mirrors restore methods.ts/domain.ts; merge S2 +worktree.list entries by name. IpcClient gains listKnowledge/saveKnowledge/ +deleteKnowledge; existing generic Tauri request path is reused. + +The compiled daemon currently has only session-specific event streams; generic +metadata broadcasts are not wired. S3 will not advertise knowledge.updated +until S2's general event transport is available. Initial UI refreshes after +mutations and provides explicit refresh; conflict protection still covers +multiple clients. Please advise the general event transport integration point. + +S1: initial components will expose KnowledgePanel({client,currentProject, +onInsert,insertDisabledReason}); onInsert receives draft text and a source reference. +The S1 acknowledgment below also requires the stored revision; S3 is adding it. +Please mount as canvas contextual panel/palette. No hidden terminal send. + ### S1 acknowledgment — 2026-09-05 S1 confirms the canvas remains the primary workspace. Deliver the saved @@ -161,22 +183,22 @@ cause is not yet established; this is in the pre-existing adapter probe path. S2 owns runtime/adapters. Please inspect or delegate a narrow test/probe fix; S3 will not alter that shared runtime without the agreed boundary. Details: https://github.com/guicybercode/Jig/actions/runs/34002926329/job/101404942360 -### S3 acknowledgment and implementation details -S2 acknowledgment received in xirp-coordination-reply.md. First implementation uses -knowledge.list/save/delete; list accepts optional projectId/kind/query/cursor, -returns {entries,nextCursor}; cursor is exclusive UUIDv7 ID ascending, literal -title/body search. Pages cap at 50 rows and 512 KiB serialized entries. Title -256 UTF-8 bytes, body 64 KiB. Prompt/context errors do not attach serde causes. -Shared additive TypeScript mirrors restore methods.ts/domain.ts; merge S2 -worktree.list entries by name. IpcClient gains listKnowledge/saveKnowledge/ -deleteKnowledge; existing generic Tauri request path is reused. +S3 read-only follow-up: fixture common::script uses fs::write then chmod0700, +probe maps all run_bounded errors to one static failure. spawn_with_retry +currently retries WouldBlock/Interrupted and raw11/35 only. Linux ETXTBSY is +a hypothesis worth checking under parallel fixture creation/fork; the CI log +does not expose the underlying errno, so this is not a diagnosed cause. -The compiled daemon currently has only session-specific event streams; generic -metadata broadcasts are not wired. S3 will not advertise knowledge.updated -until S2's general event transport is available. Initial UI refreshes after -mutations and provides explicit refresh; conflict protection still covers -multiple clients. Please advise the general event transport integration point. +## S2 organization response — 2026-09-05 + +S2 accepted S3 organization ownership and the proposed get/save contract in +S3's xirp-coordination-reply.md. Migration 0005_organization.sql is reserved +for S3; S2 workspace starts at 0006 after integration. File list/read/write +requires no migration. Shared registrations accompany functional handlers and +real SQLite/socket tests; no organization event is advertised yet. + +## S1 knowledge components checkpoint S1: initial components will expose KnowledgePanel({client,currentProject, onInsert,insertDisabledReason}); onInsert receives plain draft text and sourceId. diff --git a/docs/codex/xirp-native-capability-evidence.md b/docs/codex/xirp-native-capability-evidence.md new file mode 100644 index 0000000..ec26965 --- /dev/null +++ b/docs/codex/xirp-native-capability-evidence.md @@ -0,0 +1,62 @@ +# Native initial objective and continuity evidence + +Checked 2026-09-05 by reading the local adapters and running only the installed +CLI --help/--version commands. No agent conversation, login or private +configuration/transcript read was performed. This is interface evidence, not +proof of a successful native resume or fork. + +| Capability | codex-cli 0.153.4 | Claude Code 2.1.261 | +| --- | --- | --- | +| Initial objective | `codex [OPTIONS] [PROMPT]` | `claude [options] [prompt]` | +| Explicit resume | `codex resume [OPTIONS] [SESSION_ID] [PROMPT]` | `claude --resume [prompt]` | +| Explicit fork | `codex fork [OPTIONS] [SESSION_ID] [PROMPT]` | `claude --resume --fork-session [prompt]` | +| Predetermined conversation ID | No option identified in installed help | `--session-id ` advertised; creation not exercised | +| Model override | `--model ` | `--model ` | +| Approval/permission modes | `--ask-for-approval on-request|never` | `--permission-mode manual|acceptEdits|plan|auto|dontAsk|bypassPermissions` | +| Sandbox | `--sandbox read-only|workspace-write|danger-full-access` | No equivalent flag identified in installed help | +| Effort | No dedicated flag identified; generic --config exists | `--effort low|medium|high|xhigh|max` | + +The [official Codex command reference](https://learn.chatgpt.com/docs/developer-commands?surface=cli) +confirms positional objectives, resume/fork and model/sandbox overrides. It +also describes untrusted approval mode, absent from this installed help's +accepted values; documentation alone must not populate the version-specific +picker. The reference supports explicit --cd when choosing a different cwd. + +The [official Claude CLI reference](https://code.claude.com/docs/en/cli-reference) +confirms the corresponding flags and explains manual as an alias of default +since 2.1.200. Its additional ultracode effort is absent from installed help. +Model availability and permissions were not exercised. --print changes the +execution mode and is not an appropriate shortcut for obtaining interactive +conversation IDs. + +## Current repository gaps and S2 integration boundary + +- Codex/Claude still use the generic BuiltInAdapter. Its internal capabilities + describe interactive/PTY/version-probe/extra-args support, without typed + objective, native identity, model or permission capabilities. +- The active daemon SessionRegistry::start_id builds through + StoredAgent::command_for_cwd directly. Merely extending AgentAdapter's builder + does not affect this execution path. Spawn remains SessionManager-owned. +- CommandSpec.startup_input exists with a 4096-byte bound, but no code in + crates/session reads it in this branch. Storing a value there does not deliver + an initial objective. Session/create/start wire and stored-session metadata + also lack objective, per-session options and native conversation identity. +- AgentRecord/AgentDetection wire do not expose verified capability versions. + Display names and adapter keys must not be used to infer CLI behavior. + +S2 should define a typed new/resume/fork launch intent and bounded objective, +route the actual start path through the native adapter, and retain structured +argv. A prompt beginning with '-' requires verified argument termination so +text cannot become options. Deterministic child-argv tests can prove local +transport and redaction; they do not prove a vendor accepted the objective. + +NativeConversationId must remain distinct from the local UUIDv7 SessionId. +Avoid binding by PID, PTY, display name or a changing 'last conversation'. +Claude permits requesting a creation UUID, but successful creation/association +still needs observation. This review did not establish Codex ID acquisition. +Only expose resume/fork once both identity and native support are trustworthy. + +The [Claude session documentation](https://code.claude.com/docs/en/sessions) +explains that simultaneous resumes of one ID interleave transcript messages. +Managed resume therefore needs exclusivity; fork needs another native identity +as well as another local session. Real continuation acceptance remains open. diff --git a/docs/codex/xirp-organization-contract.md b/docs/codex/xirp-organization-contract.md new file mode 100644 index 0000000..6466788 --- /dev/null +++ b/docs/codex/xirp-organization-contract.md @@ -0,0 +1,71 @@ +# S3 organization contract proposal + +Status: approved by S2 in `xirp-coordination-reply.md`; migration +`0005_organization.sql` allocated to S3. Backend, typed IPC and migration are +published in `fce3982`; isolated controls are published in `3feb82d`. +Canvas mounting and Linux/macOS integration acceptance remain separate work. + +The original user task assigns new organization/context modules to S3 and +shared contracts to S2. This proposal resolves the earlier replies' ownership +difference with one bounded patch, independent of runtime/session execution. + +## Suggested wire contract + +- `organization.get {targets: OrganizationTarget[]}` returns `{entries}` in + request order. Between 1 and 100 distinct targets per call. Clients already + obtain project/session identities from the existing snapshot/list methods; + they can batch those IDs without a second session/project registry. +- `organization.save {target,expectedRevision,pinned,archived,workflow}` returns + one `OrganizationEntry`. A project requires `workflow:null`; a session + requires one of `backlog|in_progress|in_review|blocked|done`. No partial patch + semantics: the optimistic revision protects the entire organization record. +- A target is `{kind:"project",id:ProjectId}` or `{kind:"session",id:SessionId}`. +- An entry has `{target,pinned,archived,workflow,revision,updatedAtMs}`. Existing + entities without organization rows return defaults: false/false, null for + project or backlog for session, revision 0, updatedAtMs null. No DB row is + written by reads. The first explicit save requires expectedRevision 0 and + creates revision 1 with epoch-ms updatedAtMs. Subsequent saves require the + current positive JS-safe revision and increment it, even for reverting all + flags to defaults; rows are not deleted during ordinary edits. + +Unknown entities fail with `project_not_found`/`session_not_found` (the batch +is all-or-error). Stale saves return `organization_conflict`. Exhausted +revisions fail rather than wrap. Malformed payloads return `invalid_payload`. +No native process status, PID, worktree state, transcript or text body is +accepted by this API. + +## Persistence and concurrency + +S2 allocated migration 0005. It adds two tables, +`project_organization` and `session_organization`, each keyed by its existing +entity ID with an ON DELETE CASCADE foreign key. Keep metadata separate from +the process-owned session row. Use immediate write transactions for compare +and save, and one read snapshot for a get batch. Database constraints enforce +booleans, workflow values, revision/timestamp ranges and typed relationships. + +Pin and archive preserve files, saved knowledge, PTYs and session status. +Archiving a running session is permitted and changes only visibility metadata; +UI makes continued execution apparent. Workflow changes never start, stop or +signal any process. Deleting the underlying metadata removes its organization +row through FK only; it does not delete a repository/worktree directory. + +## UI and integration + +S3 can provide isolated selected-project/session controls and a typed batch +reader for S1's canvas/palette filters. S1 continues to own selection, sorting, +archived visibility and canvas mounting. Conflicts preserve the desired flags +and offer explicit refresh/retry with the newly observed revision. + +General metadata transport remains S2's future work; initial controls refresh +after save/reconnect and provide manual refresh. No unimplemented event name +will be added. Initial objective delivery remains a separate S2 adapter/runtime +contract; source-backed editable drafts are S3/S1's composition boundary. + +## Required evidence + +Real SQLite: defaults, persistence/reopen, concurrent first insert/update, +revision conflict/exhaustion, batch consistency, invalid/duplicate targets, +FK cascade and file preservation. Real socket: project/session workflow and +archive changes leave runtime snapshots/processes unchanged. UI: explicit +controls, stale response guards, visible running-state warning while archived, +conflict preservation. S1 mounting and Linux/macOS CI remain separate evidence. diff --git a/docs/codex/xirp-rule-sources.md b/docs/codex/xirp-rule-sources.md new file mode 100644 index 0000000..ac8c074 --- /dev/null +++ b/docs/codex/xirp-rule-sources.md @@ -0,0 +1,34 @@ +# Local rule and skill discovery sources + +Verified 2026-09-05. This inventory describes discovery candidates, not a claim +that a running agent loaded or executed every listed file. Native configuration, +trust, scope and version may change effective behavior. The app does not parse +credential-bearing settings to infer enabled plugins or custom discovery paths. + +| Format | Verified paths and precedence | Source | +| --- | --- | --- | +| Codex instructions | `CODEX_HOME` (default `~/.codex`): first nonempty `AGENTS.override.md`, then `AGENTS.md`. At each directory from repository root to cwd, same order before configured fallback names; more specific directories follow root instructions. Default aggregate bound 32 KiB. | [AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) | +| Codex skills | `.agents/skills` from cwd up to repo root, `~/.agents/skills`, `/etc/codex/skills`, plus bundled system skills. Duplicate names remain separate candidates. Symlinked skill directories are supported. | [Build skills](https://learn.chatgpt.com/docs/build-skills) | +| Claude instructions/rules | User `~/.claude/CLAUDE.md`, project `CLAUDE.md` or `.claude/CLAUDE.md`, personal `CLAUDE.local.md`. User `~/.claude/rules/*.md` and project `.claude/rules/**/*.md`; user rules precede project rules. Parent instructions load at launch, descendants can load on demand. Imports exist, but discovery must not follow arbitrary imported files. | [Memory](https://code.claude.com/docs/en/memory) | +| Claude skills | `~/.claude/skills//SKILL.md` and `.claude/skills//SKILL.md`, including parent/nested project scopes. Personal overrides project name collisions; plugin names are namespaced. Skill directories may be symlinks; aliases to the same target are deduplicated by the CLI. | [Skills](https://code.claude.com/docs/en/skills) | +| Cursor rules | Project `.cursor/rules/**/*.mdc`, with path/manual/relevance conditions. Plain `.md` files are ignored there. `AGENTS.md` is supported. Global user rules are application settings rather than standalone rule files; no invented home-file location. | [Rules](https://cursor.com/docs/rules) | +| Cursor skills | Project `.agents/skills`, `.cursor/skills` and user `~/.agents/skills`, `~/.cursor/skills`. Skill roots can contain nested groups and project subdirectories; nested candidates have directory scope. | [Skills](https://cursor.com/docs/skills) | + +## Local reader decisions to verify + +The inspector will show provider/format, global/project origin, relative scope, +source path and a precedence explanation. A candidate is not automatically +marked active: editor settings, custom fallback names, plugins, conditional +frontmatter and selected session cwd can affect native loading. + +No credential/config JSON, arbitrary Markdown imports, support scripts or +transcripts enter the inventory. The reader only opens an allowlisted rule or +`SKILL.md`, requires regular UTF-8 files and applies byte/count/depth limits. +Symlinked skill directories need an explicit verified target policy and race +protection. Rule edits will use S2's common file service once its contract is +published, rather than adding a second generic editor. + +Directory discovery and content read are local operations. They never invoke a +CLI, execute scripts, install skills, change native configuration or contact +Portal. Missing, changed, unreadable, oversized and unsafe candidates must be +reported as such rather than silently fabricated as empty files. diff --git a/docs/desktop/knowledge-sources.md b/docs/desktop/knowledge-sources.md index 09519b9..924b92a 100644 --- a/docs/desktop/knowledge-sources.md +++ b/docs/desktop/knowledge-sources.md @@ -3,7 +3,8 @@ Open **Prompts and context** from the canvas toolbar or command palette, then choose **Rules & skills**. Opening the saved library alone does not scan sources. The inspector inventories supported global/administrator locations and the -selected registered project's root configuration locations. +selected registered project's supported root and nested configuration locations. +The preview shows the owning project-relative directory for each source. Select an entry to request a read-only text preview. File contents are never rendered as HTML, executed, saved, inserted into a prompt or sent to a terminal @@ -25,8 +26,9 @@ scan/entry IDs for reading. It cannot submit a filesystem path. The daemon keeps at most four scans, expiring after five minutes, and checks that a project remains registered before reading its sources. -Inventory is bounded to 512 candidates, 10,000 visited entries and depth 16; -project locations are checked before large global libraries. Source metadata +Inventory is bounded to 512 candidates, 10,000 enumerated entries and cumulative +directory depth 16. The selected root is checked first, followed by global/admin +locations and then project descendants. Source metadata and issue lists have separate serialized byte limits. Preview is bounded to 64 KiB of UTF-8 text; truncation, unsupported scope and unavailable files are visible, not represented as a complete or successful empty inventory. @@ -38,10 +40,17 @@ opening and identity checks detect replaced parents, changed files and edits that restore the old mtime. A changed source requires rescan; the inspector is not an arbitrary file reader or editor. -This increment does not enumerate every project subfolder or bundled plugin, -evaluate native activation conditions, process imports, or inspect transcripts, -credentials and agent configuration files. Rule editing and broader source -coverage remain separate parity work. Offline discovery fails visibly and +Nested traversal skips exact VCS/dependency directory names (`.git`, +`node_modules`, `vendor`, `.venv`, `venv`) and provider configuration trees +(`.agents`, `.claude`, `.cursor`, `.codex`). The supported rule/skill locations +inside the first three provider trees are scanned by their dedicated readers. +A registered root with a normally skipped basename is still eligible. Ordinary +directory links are skipped; the displayed scan policy explains these limits. + +Sources above the registered project, effective session-cwd ancestry and bundled +plugins are not inventoried. Discovery does not evaluate native activation, +process imports, or inspect transcripts, credentials and agent configuration +files. Rule editing and broader source coverage remain separate parity work. Offline discovery fails visibly and does not fabricate a source list. ## Verification boundary diff --git a/docs/maestri-runtime-parity.md b/docs/maestri-runtime-parity.md index 472ea02..539956c 100644 --- a/docs/maestri-runtime-parity.md +++ b/docs/maestri-runtime-parity.md @@ -11,7 +11,7 @@ mantida por S1. Os IDs M01–M48 e X01–X10 continuam sendo os dela; as descri completas, fontes por recurso e critérios visuais não são duplicados aqui. Na auditoria, a matriz e `docs/codex/parallel-goals.md` foram lidos na worktree principal, `/Users/eguimacs/cli-master`, onde ainda não estavam no baseline -de S2. Publicar este recorte não substitui integrar esses documentos centrais. +de S2. Esses documentos foram posteriormente integrados pelo merge `45d817a`. ## Fontes e interpretação @@ -62,8 +62,16 @@ Os 118 testes core/daemon passaram no macOS, incluindo 9 novos de worktree; sagas/storage, Clippy e contratos frontend também foram verificados conforme [relatório S2](codex/maestri-runtime-report.md). Isso comprova o incremento de runtime M14/M34/M35; **os IDs completos continuam P**, pois floors/landing e -integração desktop não estão concluídos. Linux/CI/pacote seguem sem resultado. -Nenhum recurso posterior ganha V com essa execução. +integração desktop não estão concluídos. CI e Packaging Linux/macOS aprovaram +`45d817a`, conforme links no relatório. Isso não prova integração visual completa. + +`1225931` acrescenta list/read/write real sob alvos registrados e cliente IPC +com decodificação. No macOS passaram 13 testes de contratos file, 8 de disco/ +falhas, 12 pelo socket e 5 de ACL, além de 51 testes IPC frontend. **M29/M30 +passam de A a P neste incremento**: faltam gerenciamento completo de arquivos, +watcher, integração de editor S1 e confirmação Linux/macOS na nova CI. +A limitação APFS a nomes válidos em UTF-8 e o CAS otimista externo estão +registrados em ADR 0006. O read interno ainda não prova reúso pela S3. ## Recorte de runtime por ID central @@ -93,8 +101,8 @@ agrupadas mantêm todos os IDs para auditoria sem redefinir seu escopo. | M25 — persistência de processo | P: R8 | Distinguir cliente reconectado de daemon novo e attachment tmux verificado. | S1 mostrar capacidade real; persistência após crash exige novo ADR. | | M26 — conversa | P metadados: R8; resume A | Adapter persiste e valida identidade nativa; retoma a conversa escolhida. | Nunca reconstruir conversa a partir do PID ou de replay PTY. | | M27, M28 — ambientes | A: R3/R7 | Resolver execução, cwd, arquivo e provisionamento no mesmo host; reconexão não duplica agente. | S1 ambiente/override; SSH/Docker/custom usam argv e transporte definido em ADR. | -| M29 — arquivo | A: R3 | Listar/criar/mover/remover sob raiz registrada, tratar nomes Unix e links sem escapar do escopo. | S1 árvore; S3 reutiliza segurança de I/O. | -| M30 — editor | A: R3 | Read/write em disco com limite de tamanho e revisão esperada; mudança externa gera conflito. | S1 buffers/edição; proteger conteúdo não salvo. | +| M29 — arquivo | P: `1225931`, listagem socket macOS | Criar/mover/remover sob raiz registrada; confirmar os novos testes Linux. | S1 árvore; S3 reutiliza segurança de I/O. | +| M30 — editor | P: `1225931`, read/write socket macOS | Integrar editor real, conflitos/reload e watcher; confirmar nova CI Linux/macOS. | S1 buffers/edição; proteger conteúdo não salvo. | | M31 — busca/tabs | A: R3/R4 | Busca cancelável com paginação estável; persistência de tabs/defaults. | S1 abre arquivo/linha; definir indexação e limites após file service. | | M32 — Git local | P: R6 | Stage/unstage/commit e descarte revisado no repositório derivado do alvo. | S1 diff real; descarte precisa prova de estado, não um booleano force. | | M33 — Git remoto/histórico | A: R6 | Git argv real com exclusão mútua, cancelamento e erros acionáveis. | S1 opções/history; credenciais continuam com Git do usuário. | diff --git a/docs/xirp-local-parity.md b/docs/xirp-local-parity.md index d51a5d0..df3eefc 100644 --- a/docs/xirp-local-parity.md +++ b/docs/xirp-local-parity.md @@ -8,18 +8,18 @@ added to this implementation's scope. | ID | Requirement and source | Owner / integration | Evidence / remaining work | | --- | --- | --- | --- | -| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline real daemon tests register both Git projects and plain folders; pin/archive metadata and child discovery remain pending. Project archive is a user-requested extension. | +| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline real daemon tests register both Git projects and plain folders; pin/archive metadata now persists via organization.get/save (`fce3982`); isolated controls and runtime-preservation tests pass, while canvas filtering and child discovery remain pending. Project archive is a user-requested extension. | | X02 | Initial objective delivered to agent, attachments, checkout/worktree and agent-specific options. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 execution/contracts, S3 draft/context selection, S1 composer | Goal delivery requires verified runtime path, not metadata only. Attachments/options pending runtime contract. | | X03 | Resume/fork, agent switch preserving supported history, linked shell. [Sessions](https://backstage.spotify.com/docs/xirp/sessions), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapters/runtime, S1 canvas | Current adapter capabilities do not expose verified conversation IDs, fork or usage. Await trusted native contracts and runtime integration. Local context reuse is a separate feature. | | X04 | Working/idle/needs-input attention separate from process lifecycle. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 event source, S3 indicators, S1 canvas | PTY silence is not proof of approval/input need. Explicit hooks or native signals required. | | X05 | Local preferences, shortcuts, supported native settings, diagnostics. [Settings](https://backstage.spotify.com/docs/xirp/settings) | S2 settings/runtime, S3 rules/skills, S1 UI | Existing diagnostics/custom-agent creation are baseline only; do not expose unsupported options. Credentials excluded from editors. | -| X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Need verified location inventory and safe bounded reads with origins/scopes; docs do not enumerate complete formats or precedence. Symlink policy and editing depend on file service. | -| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Persisted CRUD and paginated search verified through real SQLite and daemon socket; isolated panel CRUD/draft tests and responsive Chromium preview passed; canvas and Linux evidence pending. See S3 report. | -| X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 organization metadata, S3 controls, S1 filtering | Workflow must not mutate lifecycle. Session pin is a user-requested extension. | +| X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Known root/global/admin locations now have bounded metadata discovery and explicit UTF-8 reads, with origins/scopes/precedence caveats and skill-link target capture (`4a367d0`). Socket, core and isolated inspector checks pass in Linux/macOS CI at `41f679c`. S1 inspector mounting `9329d01` is reconciled into this branch. Nested project subtree inventory (`8f5ff86`) passes 31 focused macOS daemon tests, including scope/limit/replacement regressions; its new CI is pending. Sources above the registered project, session-cwd ancestry, native activation and source editing remain open. | +| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Persisted CRUD and paginated search verified through real SQLite and daemon socket; isolated panel CRUD/draft tests and responsive Chromium preview passed; canvas library/composer insertion is integrated through S1 `1150de6` and passes Chromium E2E; Linux/macOS CI and Packaging pass at `41f679c`. See S3 report. | +| X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 organization modules/controls, S2 contract review, S1 filtering | Workflow and pin/archive persist separately from lifecycle (`fce3982`); real live-process socket tests and isolated controls pass in Linux/macOS CI at `41f679c`. Canvas filtering/mounting remains pending. Session pin is a user-requested extension. | | X07c | Tokens/spend per session/day. [Changelog v0.18.0](https://backstage.spotify.com/docs/xirp/changelog) | S2 verified native data, S3 presentation | Source documents feature existence, not a collector/format/pricing algorithm. Currently unavailable, not zero. No proxy or PTY-based estimates. | | X07d | PR review, Doctor, cleanup. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 runtime/Git, S1 UI | Existing Git status/diagnostics/removal-token safety are baseline; review and expanded cleanup require their real contracts. | | X08 | Agent-specific options, including Cursor. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapter registry, S1 UI | No flags invented from agent display name; support requires installed-version tests and authoritative CLI sources. | -| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records persist and survive daemon restart; isolated draft insertion component and tests passed; canvas consumption remains pending. No transcript capture, autonomous summarization, or Portal connection implied. | +| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records persist and survive daemon restart; isolated draft insertion component and tests passed; explicit editable canvas snapshot consumption is integrated through S1 `1150de6` and passes Chromium E2E. No transcript capture, autonomous summarization, or Portal connection implied. | | X09-Portal | Shared workspaces, members, catalog links, resources, decisions/wiki. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector | Portal-dependent; no account/API verification supplied. Not implemented or simulated. | | X10 | Workspace context via MCP, manual transcript sharing, configured external integrations. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector + S2 transport | Requires real account, scopes/API and explicit outbound preview. No silent publishing, account linking or telemetry. | diff --git a/protocol/catalog.json b/protocol/catalog.json index e93afb0..b539b54 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -30,12 +30,17 @@ "worktree.list", "worktree.prepare_remove", "worktree.remove", + "file.list", + "file.read", + "file.write", "diagnostics.get", "knowledge.list", "knowledge.save", "knowledge.delete", "knowledge.discover", - "knowledge.read" + "knowledge.read", + "organization.get", + "organization.save" ], "events": [ "project.updated", diff --git a/protocol/fixtures/organization.json b/protocol/fixtures/organization.json new file mode 100644 index 0000000..bd93eaa --- /dev/null +++ b/protocol/fixtures/organization.json @@ -0,0 +1,51 @@ +{ + "request": { + "targets": [ + { + "kind": "session", + "id": "0198b6e0-0001-7000-8000-000000000001" + }, + { + "kind": "project", + "id": "0198b6e0-0000-7000-8000-000000000001" + } + ] + }, + "response": { + "entries": [ + { + "target": { + "kind": "session", + "id": "0198b6e0-0001-7000-8000-000000000001" + }, + "pinned": false, + "archived": false, + "workflow": "backlog", + "revision": 0, + "updatedAtMs": null + }, + { + "target": { + "kind": "project", + "id": "0198b6e0-0000-7000-8000-000000000001" + }, + "pinned": false, + "archived": false, + "workflow": null, + "revision": 0, + "updatedAtMs": null + } + ] + }, + "saved": { + "target": { + "kind": "session", + "id": "0198b6e0-0001-7000-8000-000000000001" + }, + "pinned": true, + "archived": true, + "workflow": "in_review", + "revision": 2, + "updatedAtMs": 1788657000000 + } +}