fix(server): authorize active workspace config

This commit is contained in:
Mohamed Boudra
2026-07-19 07:28:45 +02:00
parent 2505075beb
commit 194822b939
3 changed files with 134 additions and 7 deletions

View File

@@ -838,6 +838,7 @@ export class Session {
emit: (msg) => this.emit(msg),
},
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
});
this.daemonSession = new DaemonSession({

View File

@@ -4,7 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
@@ -33,12 +33,37 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers
};
}
function makeSubsystem(records: PersistedProjectRecord[]) {
function workspaceRecord(
projectId: string,
cwd: string,
archivedAt: string | null = null,
): PersistedWorkspaceRecord {
return {
workspaceId: `workspace:${cwd}`,
projectId,
cwd,
kind: "worktree",
displayName: "Workspace",
branch: "feature",
worktreeRoot: cwd,
isPaseoOwnedWorktree: true,
mainRepoRoot: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt,
};
}
function makeSubsystem(
records: PersistedProjectRecord[],
workspaces: PersistedWorkspaceRecord[] = [],
) {
const emitted: SessionOutboundMessage[] = [];
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new ProjectConfigSession({
host,
projectRegistry: { list: async () => records },
workspaceRegistry: { list: async () => workspaces },
logger: pino({ level: "silent" }),
});
return { subsystem, emitted };
@@ -179,6 +204,92 @@ describe("ProjectConfigSession", () => {
]);
});
test("read and write accept an active workspace belonging to an active project", async () => {
const repoRoot = makeRoot();
const workspaceRoot = makeRoot();
const project = projectRecord(repoRoot);
const { subsystem, emitted } = makeSubsystem(
[project],
[workspaceRecord(project.projectId, workspaceRoot)],
);
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "workspace-write-1",
repoRoot: workspaceRoot,
config: { worktree: { setup: "npm ci" } },
expectedRevision: null,
});
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "workspace-read-1",
repoRoot: workspaceRoot,
});
expect(emitted).toEqual([
expect.objectContaining({
type: "write_project_config_response",
payload: expect.objectContaining({ ok: true, repoRoot: workspaceRoot }),
}),
expect.objectContaining({
type: "read_project_config_response",
payload: expect.objectContaining({
ok: true,
repoRoot: workspaceRoot,
config: { worktree: { setup: "npm ci" } },
}),
}),
]);
});
test("read rejects archived workspaces and workspaces owned by archived projects", async () => {
const activeRepoRoot = makeRoot();
const archivedRepoRoot = makeRoot();
const archivedWorkspaceRoot = makeRoot();
const orphanedWorkspaceRoot = makeRoot();
const activeProject = projectRecord(activeRepoRoot);
const archivedProject = projectRecord(archivedRepoRoot, "2026-01-02T00:00:00.000Z");
const { subsystem, emitted } = makeSubsystem(
[activeProject, archivedProject],
[
workspaceRecord(activeProject.projectId, archivedWorkspaceRoot, "2026-01-02T00:00:00.000Z"),
workspaceRecord(archivedProject.projectId, orphanedWorkspaceRoot),
],
);
for (const [requestId, repoRoot] of [
["archived-workspace", archivedWorkspaceRoot],
["archived-owner", orphanedWorkspaceRoot],
] as const) {
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId,
repoRoot,
});
}
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "archived-workspace",
repoRoot: archivedWorkspaceRoot,
ok: false,
error: { code: "project_not_found" },
},
},
{
type: "read_project_config_response",
payload: {
requestId: "archived-owner",
repoRoot: orphanedWorkspaceRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));

View File

@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
import { resolve, sep } from "path";
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ProjectRegistry } from "../../workspace-registry.js";
import type { ProjectRegistry, WorkspaceRegistry } from "../../workspace-registry.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
@@ -16,24 +16,27 @@ export interface ProjectConfigSessionHost {
export interface ProjectConfigSessionOptions {
host: ProjectConfigSessionHost;
projectRegistry: Pick<ProjectRegistry, "list">;
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
logger: pino.Logger;
}
/**
* A client's read/write surface for a project's on-disk paseo.json. Resolves the
* request's repoRoot against the known (non-archived) project roots — accepting a
* trailing slash or a symlink via realpath — then reads or writes the config
* substrate and emits the matching response. Reaches no state beyond the injected
* project registry and the outbound channel.
* request's repoRoot against known active project and workspace roots — accepting
* a trailing slash or a symlink via realpath — then reads or writes the config
* substrate and emits the matching response. Workspace roots remain authorized
* only while both the workspace and its owning project are active.
*/
export class ProjectConfigSession {
private readonly host: ProjectConfigSessionHost;
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
private readonly workspaceRegistry: Pick<WorkspaceRegistry, "list">;
private readonly logger: pino.Logger;
constructor(options: ProjectConfigSessionOptions) {
this.host = options.host;
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.logger = options.logger;
}
@@ -153,15 +156,27 @@ export class ProjectConfigSession {
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const projects = await this.projectRegistry.list();
const activeProjectIds = new Set<string>();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
activeProjectIds.add(project.projectId);
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
const workspaces = await this.workspaceRegistry.list();
for (const workspace of workspaces) {
if (workspace.archivedAt !== null || !activeProjectIds.has(workspace.projectId)) {
continue;
}
const workspaceRoot = canonicalizeConfigRoot(workspace.cwd);
if (requestedRoot === workspaceRoot) {
return workspaceRoot;
}
}
return null;
}
}