Merge main into the legacy cleanup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 16:51:55 +00:00
commit bf23bd43c6
No known key found for this signature in database
19 changed files with 503 additions and 151 deletions

View File

@ -256,9 +256,23 @@ jobs:
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; } grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
done done
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT" echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
# GitHub artifact attestations are unavailable for private repos on personal accounts. # Owner type is not exposed on the github context; resolve it from the repository metadata API.
- name: Resolve registry attestation eligibility
id: attestation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Attest the published image - name: Attest the published image
if: github.event.repository.private != true || github.repository_owner_type != 'User' if: steps.attestation.outputs.enabled == 'true'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with: with:
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}

View File

@ -10,6 +10,7 @@
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake. - Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default. - Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push. - The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved. - After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author. - PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown. - Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -1,11 +1,42 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type * as db from "@rakazo/db";
import { SpaceLimitError } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const owner = { userId: "user-owner", isDeploymentOwner: true }; const createSpaceForMember = vi.hoisted(() => vi.fn());
const member = { userId: "user-member", isDeploymentOwner: false }; vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
}));
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
spaces.push(space);
return { id: space.id, name: space.name };
});
const prisma = {
space: {
findMany: async () => spaces.filter((space) => space.workspacePath),
update: async ({ where, data }: SpaceUpdate) => {
const space = spaces.find((candidate) => candidate.id === where.id);
if (space) space.workspacePath = data.workspacePath;
},
},
} as unknown as db.PrismaClient;
return { prisma, spaces };
}
async function scanHomeWithOneFile(): Promise<string> { async function scanHomeWithOneFile(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise<string> {
return home; return home;
} }
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
const home = await scanHomeWithOneFile();
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
return home;
}
async function completed(scanId: string, userId: string) { async function completed(scanId: string, userId: string) {
return await vi.waitFor( return await vi.waitFor(
async () => { async () => {
@ -25,13 +62,21 @@ async function completed(scanId: string, userId: string) {
); );
} }
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
function allowHostScan() {
vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
}
describe("onboarding scan", () => { describe("onboarding scan", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
createSpaceForMember.mockReset();
}); });
it("hides a scan from every account except the one that started it", async () => { it("hides a scan from every account except the one that started it", async () => {
const { scanId } = await startOnboardingScan({ actor: member }, {}); const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
status: expect.any(String), status: expect.any(String),
@ -40,25 +85,77 @@ describe("onboarding scan", () => {
}); });
it("never reads the host without a configured scan home", async () => { it("never reads the host without a configured scan home", async () => {
const { scanId } = await startOnboardingScan({ actor: owner }, {}); const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
const status = await completed(scanId, owner.userId); const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete"); expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
}); });
it("lets only the deployment owner discover the configured scan home", async () => { it("lets only the deployment owner discover the configured scan home", async () => {
// The shared test setup pins the emulated runtime, which also keeps the scan simulated. allowHostScan();
vi.stubEnv("AGENT_RUNTIME", ""); const scanHome = await scanHomeWithRepos("Projects/app");
vi.stubEnv("SANDBOX_PROVIDER", ""); const { prisma } = fakeDatabase();
const scanHome = await scanHomeWithOneFile();
const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
const memberStatus = await completed(asMember.scanId, member.userId); const memberStatus = await completed(asMember.scanId, member.userId);
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
expect(createSpaceForMember).not.toHaveBeenCalled();
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const ownerStatus = await completed(asOwner.scanId, owner.userId); const ownerStatus = await completed(asOwner.scanId, owner.userId);
expect(ownerStatus.status).toBe("complete"); expect(ownerStatus.status).toBe("complete");
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
}); });
it("links each repository of the deployment owner to one project of its own", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
const app = await realpath(join(scanHome, "Projects", "app"));
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const firstStatus = await completed(first.scanId, owner.userId);
expect(firstStatus.status).toBe("complete");
expect(firstStatus.result).toMatchObject({
localRepos: [{ name: "app" }],
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
});
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
currentSpaceId: owner.spaceId,
userId: owner.userId,
name: "app",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const againStatus = await completed(again.scanId, owner.userId);
expect(againStatus.result).toMatchObject({ createdProjects: [] });
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
});
it("joins a running scan instead of linking its repositories twice", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
expect(second.scanId).toBe(first.scanId);
await completed(first.scanId, owner.userId);
expect(spaces).toHaveLength(1);
});
it("stops creating projects at the space limit and still completes", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
const { prisma } = fakeDatabase({ spaceLimit: 1 });
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
});
}); });

View File

@ -1,9 +1,16 @@
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. // Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
// Runs synchronously on the host machine (no sandbox needed). // Runs synchronously on the host machine (no sandbox needed).
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { access, readdir, stat } from "node:fs/promises"; import { access, readdir, realpath, stat } from "node:fs/promises";
import { dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import type { Actor } from "@rakazo/contracts"; import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
InvalidSpaceNameError,
SpaceLimitError,
wireProjectWorkspace,
} from "@rakazo/db";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -54,10 +61,9 @@ interface ProfileSummary {
} }
interface CreatedProject { interface CreatedProject {
id: string;
name: string;
spaceId: string; spaceId: string;
description: string; name: string;
path: string;
} }
interface ScanRecord { interface ScanRecord {
@ -115,8 +121,10 @@ async function runSimulatedScan(record: ScanRecord) {
record.completedAt = new Date(); record.completedAt = new Date();
} }
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
export interface OnboardingScanRequest { export interface OnboardingScanRequest {
actor: Pick<Actor, "userId" | "isDeploymentOwner">; actor: ScanActor;
botId?: string; botId?: string;
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
scanHome?: string; scanHome?: string;
@ -124,8 +132,12 @@ export interface OnboardingScanRequest {
export async function startOnboardingScan( export async function startOnboardingScan(
{ actor, botId, scanHome }: OnboardingScanRequest, { actor, botId, scanHome }: OnboardingScanRequest,
prisma: any, prisma: PrismaClient,
): Promise<{ scanId: string }> { ): Promise<{ scanId: string }> {
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
for (const running of scanRegistry.values()) {
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
}
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const record: ScanRecord = { const record: ScanRecord = {
id: scanId, id: scanId,
@ -154,7 +166,7 @@ export async function startOnboardingScan(
await runSimulatedScan(record); await runSimulatedScan(record);
return; return;
} }
const result = await executeFullScan(scanHome, botId ?? "", prisma); const result = await executeFullScan(scanHome, actor, prisma);
record.status = "complete"; record.status = "complete";
record.progress = 100; record.progress = 100;
record.message = "Scan complete — profile ready"; record.message = "Scan complete — profile ready";
@ -189,7 +201,11 @@ export async function getScanStatus(
// Full scan pipeline // Full scan pipeline
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise<ScanResult> { async function executeFullScan(
homeDir: string,
actor: ScanActor,
prisma: PrismaClient,
): Promise<ScanResult> {
// Phase 1: Local file scan // Phase 1: Local file scan
const [fileSummary, projectFolders] = await scanFiles(homeDir); const [fileSummary, projectFolders] = await scanFiles(homeDir);
@ -200,8 +216,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
// Phase 4: Profile compilation from findings // Phase 4: Profile compilation from findings
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
// Phase 6: Project folder creation // Phase 6: Projects for discovered repositories
const createdProjects = await createProjectFolders(localRepos, prisma); const createdProjects = await createProjects(localRepos, actor, prisma);
// Phase 7: Sub-agent role creation // Phase 7: Sub-agent role creation
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
@ -413,9 +429,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
if (entry.name === ".git") { if (entry.name === ".git") {
results.push(dirname(fullPath)); results.push(dirname(fullPath));
} else if ( } else if (
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
!entry.name.startsWith(".") &&
entry.name !== "node_modules" && entry.name !== "node_modules" &&
entry.name !== ".git" &&
entry.name !== ".venv" &&
entry.name !== "dist" entry.name !== "dist"
) { ) {
results.push(...(await findGitDirectories(fullPath, depth + 1))); results.push(...(await findGitDirectories(fullPath, depth + 1)));
@ -622,38 +638,43 @@ function generateBriefing(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase 5: Project folder creation // Phase 5: Projects for discovered repositories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> { /** Give each discovered repository a project linked to its folder, unless one already links it. */
async function createProjects(
repos: RepoInfo[],
actor: ScanActor,
prisma: PrismaClient,
): Promise<CreatedProject[]> {
const linked = await prisma.space.findMany({
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
select: { workspacePath: true },
});
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
const created: CreatedProject[] = []; const created: CreatedProject[] = [];
for (const repo of repos) { for (const repo of repos) {
// Compare and link the real folder, so the next scan recognizes the projects of this one.
const folder = await realpath(repo.path).catch(() => null);
if (!folder || linkedFolders.has(folder)) continue;
let space: { id: string; name: string };
try { try {
// Check if space already exists space = await createSpaceForMember(prisma, {
const existing = await prisma.space.findFirst({ where: { title: repo.name } }); currentSpaceId: actor.spaceId,
if (existing) { userId: actor.userId,
created.push({ name: basename(folder),
id: existing.id,
name: repo.name,
spaceId: existing.id,
description: repo.remote || repo.path,
}); });
continue; } catch (error) {
if (error instanceof SpaceLimitError) break;
if (error instanceof InvalidSpaceNameError) continue;
throw error;
} }
// Create new space const workspace = await wireProjectWorkspace(prisma, {
const space = await prisma.space.create({
data: { title: repo.name, path: repo.path },
});
created.push({
id: space.id,
name: repo.name,
spaceId: space.id, spaceId: space.id,
description: repo.remote || repo.path, workspacePath: folder,
}); });
} catch { created.push({ spaceId: space.id, name: space.name, path: workspace.path });
// Space might already exist or DB unavailable — skip
}
} }
return created; return created;

View File

@ -569,6 +569,10 @@ export function createRouter(deps: RouterDeps) {
spaceNavigationDto(deps, context.actor, repos, groupRepos), spaceNavigationDto(deps, context.actor, repos, groupRepos),
), ),
create: authed.spaces.create.handler(async ({ context, input }) => { create: authed.spaces.create.handler(async ({ context, input }) => {
// A linked folder lives on the host, and the host is the deployment owner's machine.
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
let space: { id: string; name: string }; let space: { id: string; name: string };
try { try {
space = await createSpaceForMember(deps.prisma, { space = await createSpaceForMember(deps.prisma, {
@ -2230,10 +2234,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
return entries.map((entry) => ({ return entries.map((entry) => ({
...entry, ...entry,
@ -2281,10 +2282,7 @@ export function createRouter(deps: RouterDeps) {
} }
} }
if (isProjectWorkspacePath(storedPath)) { if (isProjectWorkspacePath(storedPath)) {
const workspaceRoot = await requireProjectWorkspaceRoot( const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
deps.prisma,
context.actor.spaceId,
);
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
}); });
@ -4894,10 +4892,13 @@ function mapUpdaterError(error: unknown): never {
async function requireProjectWorkspaceRoot( async function requireProjectWorkspaceRoot(
prisma: Pick<PrismaClient, "space">, prisma: Pick<PrismaClient, "space">,
spaceId: string, actor: Actor,
): Promise<string> { ): Promise<string> {
// Only the deployment owner may link a host folder, so only they may browse one. This also
// closes folders that other members linked before linking was limited to the owner.
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const space = await prisma.space.findUnique({ const space = await prisma.space.findUnique({
where: { id: spaceId }, where: { id: actor.spaceId },
select: { workspacePath: true }, select: { workspacePath: true },
}); });
if (!space?.workspacePath) { if (!space?.workspacePath) {
@ -5009,7 +5010,8 @@ async function spaceNavigationDto(
memberships.length > 1 && memberships.length > 1 &&
!spacesWithContent.has(membership.spaceId) && !spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
workspace: projectWorkspaceDto(membership.space), // A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map((bot) => ({ bots: spaceBots.map((bot) => ({
id: bot.id, id: bot.id,
spaceId: bot.spaceId, spaceId: bot.spaceId,

View File

@ -16,11 +16,12 @@ test.use({ viewport: { width: 1280, height: 1000 } });
/** /**
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every * Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts. * visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
* and in elements matching `skip`.
*/ */
function unreadableTexts(locator: Locator, inside: boolean) { function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
return locator.evaluate( return locator.evaluate(
(root, { inside, min }) => { (root, { inside, skip, min }) => {
const context = document const context = document
.createElement("canvas") .createElement("canvas")
.getContext("2d", { willReadFrequently: true }); .getContext("2d", { willReadFrequently: true });
@ -81,7 +82,11 @@ function unreadableTexts(locator: Locator, inside: boolean) {
}; };
const elements = inside const elements = inside
? [root, ...root.querySelectorAll("*")].filter( ? [root, ...root.querySelectorAll("*")].filter(
(element) => ownText(element) && shown(element) && !element.closest(":disabled"), (element) =>
ownText(element) &&
shown(element) &&
!element.closest(":disabled") &&
!(skip && element.closest(skip)),
) )
: [root]; : [root];
return elements.flatMap((element) => { return elements.flatMap((element) => {
@ -90,14 +95,18 @@ function unreadableTexts(locator: Locator, inside: boolean) {
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : []; return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
}); });
}, },
{ inside, min: MIN_CONTRAST }, { inside, skip, min: MIN_CONTRAST },
); );
} }
async function expectReadable(locator: Locator, what: string, { inside = false } = {}) { async function expectReadable(
locator: Locator,
what: string,
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
) {
await expect(locator).toBeVisible(); await expect(locator).toBeVisible();
await expect await expect
.poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` }) .poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]); .toEqual([]);
} }
@ -213,6 +222,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await composer.fill("Draft a note about owls"); await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar"); const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
await inEachTheme(page, async (theme) => { await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`); await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
@ -220,24 +232,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, { await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
inside: true, inside: true,
}); });
await expectReadable( // The header carries the logo, which WCAG exempts; its create button is checked on its own.
sidebar.getByRole("button", { name: "Heute", exact: true }), await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
`${theme}: Heute`, await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
{ inside: true },
);
await expectReadable(
sidebar.locator("[data-main-conversation]"),
`${theme}: the main conversation row`,
{ inside: true },
);
await expectReadable(
sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }),
`${theme}: the selected bot row`,
{ inside: true },
);
await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, {
inside: true,
});
}); });
await openNewBot(page); await openNewBot(page);
@ -256,4 +253,12 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`); await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
}); });
await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
});
}); });

View File

@ -3,6 +3,7 @@ import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
openNewSpace, openNewSpace,
routePatchRpc,
rpc, rpc,
signup, signup,
unterhaltungenOnly, unterhaltungenOnly,
@ -45,6 +46,30 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
}); });
for (const owner of [true, false]) {
const role = owner ? "owner" : "member";
const title = owner
? "new project dialog offers a host folder to the deployment owner"
: "new project dialog offers no host folder to a member";
test(title, async ({ page }, testInfo) => {
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
await completeOnboarding(page);
// Whoever signs up first owns the deployment, which depends on spec order.
await routePatchRpc(page, "bootstrap", (json) => ({
...json,
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
}));
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await expect(dialog.getByLabel("Name")).toBeVisible();
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
await captureScreenshot(page, testInfo, `new-project-${role}`);
});
}
test("spaces stay invisible by default and chat creation requires approval", async ({ test("spaces stay invisible by default and chat creation requires approval", async ({
page, page,
}, testInfo) => { }, testInfo) => {

View File

@ -178,7 +178,7 @@ export function HeuteBrief({
href={MISSION_CONTROL_URL} href={MISSION_CONTROL_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground" className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
data-testid="heute-mission-control-link" data-testid="heute-mission-control-link"
> >
<Trans>Mission Control</Trans> <Trans>Mission Control</Trans>
@ -235,7 +235,7 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
function ActionableChip({ label }: { label: string }) { function ActionableChip({ label }: { label: string }) {
return ( return (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning"> <span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{label} {label}
</span> </span>
); );

View File

@ -3451,7 +3451,7 @@ export function ShellPage() {
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${ className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
activityMode activityMode
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground/70 hover:text-foreground/75" : "rk-sidebar-muted hover:text-foreground/75"
}`} }`}
> >
<Bell <Bell
@ -3464,7 +3464,7 @@ export function ShellPage() {
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}> <Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
<PopoverTrigger <PopoverTrigger
className="app-no-drag text-[21px] text-muted-foreground/70 hover:text-foreground/75" className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
title={t`Create`} title={t`Create`}
data-testid="create-menu-trigger" data-testid="create-menu-trigger"
> >
@ -3587,7 +3587,7 @@ export function ShellPage() {
type="button" type="button"
aria-label={t`Neue Unterhaltung`} aria-label={t`Neue Unterhaltung`}
data-testid="conversation-picker-trigger" data-testid="conversation-picker-trigger"
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors" className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
> >
<Plus strokeWidth={2.5} className="size-icon-md" /> <Plus strokeWidth={2.5} className="size-icon-md" />
</PopoverTrigger> </PopoverTrigger>
@ -3628,7 +3628,7 @@ export function ShellPage() {
</div> </div>
) : null} ) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? ( {conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label text-muted-foreground"> <div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`Noch keine Unterhaltung.`}</p> <p>{t`Noch keine Unterhaltung.`}</p>
<Button <Button
variant="outline" variant="outline"
@ -3652,7 +3652,7 @@ export function ShellPage() {
<div className="flex items-center pt-3 pb-0.5"> <div className="flex items-center pt-3 pb-0.5">
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => { onClick={() => {
if (group.emptySpaceId) { if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/onboarding"); openSpaceChat(group.emptySpaceId, "/onboarding");
@ -3836,7 +3836,7 @@ export function ShellPage() {
{item.chat.name} {item.chat.name}
</span> </span>
{needsYouChipLabel(item.chat.status) ? ( {needsYouChipLabel(item.chat.status) ? (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning"> <span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{needsYouChipLabel(item.chat.status)} {needsYouChipLabel(item.chat.status)}
</span> </span>
) : item.chat.title ? ( ) : item.chat.title ? (
@ -3882,7 +3882,7 @@ export function ShellPage() {
[group.key]: !prev[group.key], [group.key]: !prev[group.key],
})) }))
} }
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors" className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
> >
{expandedTeamGroups[group.key] {expandedTeamGroups[group.key]
? "Weniger anzeigen" ? "Weniger anzeigen"
@ -3897,7 +3897,7 @@ export function ShellPage() {
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5"> <div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
<div> <div>
<div className="rk-shell-section-label">{t`Projekte`}</div> <div className="rk-shell-section-label">{t`Projekte`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70"> <p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
{t`Threads by topic · folders`} {t`Threads by topic · folders`}
</p> </p>
</div> </div>
@ -3907,14 +3907,14 @@ export function ShellPage() {
aria-label={t`Create a project`} aria-label={t`Create a project`}
data-testid="create-project-trigger" data-testid="create-project-trigger"
onClick={() => openNewSpaceDialog("project")} onClick={() => openNewSpaceDialog("project")}
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors" className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
> >
<Plus strokeWidth={2.5} className="size-icon-md" /> <Plus strokeWidth={2.5} className="size-icon-md" />
</button> </button>
</div> </div>
</div> </div>
{projectSidebarGroups.length === 0 ? ( {projectSidebarGroups.length === 0 ? (
<div className="p-4 text-center text-ui-label text-muted-foreground"> <div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`No projects yet.`}</p> <p>{t`No projects yet.`}</p>
<Button <Button
variant="outline" variant="outline"
@ -3952,7 +3952,7 @@ export function ShellPage() {
</ProjectDragGrip> </ProjectDragGrip>
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => { onClick={() => {
if (group.emptySpaceId) { if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app"); openSpaceChat(group.emptySpaceId, "/app");
@ -4148,7 +4148,7 @@ export function ShellPage() {
type="button" type="button"
aria-expanded={archivedOpen} aria-expanded={archivedOpen}
onClick={() => setArchivedOpen((open) => !open)} onClick={() => setArchivedOpen((open) => !open)}
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body text-muted-foreground hover:bg-sidebar-accent" className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body rk-sidebar-muted hover:bg-sidebar-accent"
> >
<span> <span>
<Trans>Archived</Trans> <Trans>Archived</Trans>
@ -5469,6 +5469,7 @@ export function ShellPage() {
{newSpaceOpen ? ( {newSpaceOpen ? (
<NewSpaceDialog <NewSpaceDialog
variant={newSpaceDialogVariant} variant={newSpaceDialogVariant}
canLinkFolder={bootstrapMe?.isDeploymentOwner === true}
onCancel={() => setNewSpaceOpen(false)} onCancel={() => setNewSpaceOpen(false)}
onConfirm={async ({ name, workspacePath }) => { onConfirm={async ({ name, workspacePath }) => {
const space = await rpc.spaces.create({ const space = await rpc.spaces.create({

View File

@ -38,10 +38,13 @@ export type NewSpaceConfirmInput = {
export function NewSpaceDialog({ export function NewSpaceDialog({
variant = "space", variant = "space",
canLinkFolder,
onCancel, onCancel,
onConfirm, onConfirm,
}: { }: {
variant?: "space" | "project"; variant?: "space" | "project";
/** Folders live on the host, which only the deployment owner may link. */
canLinkFolder: boolean;
onCancel: () => void; onCancel: () => void;
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>; onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
}) { }) {
@ -53,6 +56,7 @@ export function NewSpaceDialog({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isProject = variant === "project"; const isProject = variant === "project";
const showFolder = isProject && canLinkFolder;
const desktop = desktopBridge(); const desktop = desktopBridge();
const trimmedWorkspacePath = workspacePath.trim(); const trimmedWorkspacePath = workspacePath.trim();
@ -63,7 +67,7 @@ export function NewSpaceDialog({
setError(null); setError(null);
void onConfirm({ void onConfirm({
name: trimmed, name: trimmed,
...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), ...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
}).catch((reason: unknown) => { }).catch((reason: unknown) => {
setError( setError(
reason instanceof Error reason instanceof Error
@ -104,7 +108,7 @@ export function NewSpaceDialog({
className="mt-2" className="mt-2"
/> />
</label> </label>
{isProject ? ( {showFolder ? (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor={folderId} className="block text-ui-body text-foreground/75"> <label htmlFor={folderId} className="block text-ui-body text-foreground/75">
<Trans>Project folder</Trans> <Trans>Project folder</Trans>

View File

@ -11,9 +11,7 @@ export function rosterNameClass(unread: boolean): string {
return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground"; return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground";
} }
// Muted text measures about 4.4:1 on the light sidebar; a dimmed foreground stays above 4.5:1. export const rosterTimeClass = "text-ui-body-sm rk-sidebar-muted tabular-nums font-medium";
export const rosterTimeClass =
"text-ui-body-sm text-sidebar-foreground/65 tabular-nums font-medium";
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground"; export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";

View File

@ -73,7 +73,7 @@ body,
/* Shell layout utilities — token-backed, use instead of one-off pixel classes */ /* Shell layout utilities — token-backed, use instead of one-off pixel classes */
@layer components { @layer components {
.rk-shell-section-label { .rk-shell-section-label {
@apply text-ui-label font-bold tracking-wider uppercase text-muted-foreground/80; @apply text-ui-label font-bold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80;
} }
.rk-shell-icon-btn { .rk-shell-icon-btn {
@ -84,6 +84,12 @@ body,
@apply text-ui-body-sm text-muted-foreground; @apply text-ui-body-sm text-muted-foreground;
} }
/* Secondary sidebar text and icons. Muted text measures 4.4:1 on the light sidebar;
a dimmed foreground stays above 4.5:1. */
.rk-sidebar-muted {
@apply text-sidebar-foreground/65;
}
.rk-shell-row-title { .rk-shell-row-title {
@apply text-ui-body font-medium text-foreground; @apply text-ui-body font-medium text-foreground;
} }

View File

@ -38,12 +38,14 @@ describe("server image publish workflow", () => {
expect(publish.needs).toBe("build"); expect(publish.needs).toBe("build");
expect(workflowText).toContain("push-by-digest=true"); expect(workflowText).toContain("push-by-digest=true");
expect(workflowText).toContain("docker buildx imagetools create"); expect(workflowText).toContain("docker buildx imagetools create");
expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]'); expect(workflowText).toContain(`if [[ "\${#sources[@]}" -ne 1 ]]`);
expect(workflowText).toContain("for want in linux/amd64; do"); expect(workflowText).toContain("for want in linux/amd64; do");
expect(workflowText).toContain("actions/attest-build-provenance@"); expect(workflowText).toContain("actions/attest-build-provenance@");
expect(workflowText).toContain( expect(workflowText).not.toContain("github.repository_owner_type");
"if: github.event.repository.private != true || github.repository_owner_type != 'User'", expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
); expect(workflowText).toContain(".owner.type");
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
}); });
it("stores digest markers under RUNNER_TEMP so self-hosted runners do not leak stale files", () => { it("stores digest markers under RUNNER_TEMP so self-hosted runners do not leak stale files", () => {
@ -52,11 +54,11 @@ describe("server image publish workflow", () => {
}); });
it("names every image under the lowercase repository namespace", () => { it("names every image under the lowercase repository namespace", () => {
expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}"); expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`);
const references = const references =
workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? []; workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
expect(references.length).toBeGreaterThanOrEqual(6); expect(references.length).toBeGreaterThanOrEqual(6);
expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}"); expect(workflowText).toContain(`repo=\${GITHUB_REPOSITORY,,}`);
}); });
it("keeps pull requests read-only and every action pinned to a commit", () => { it("keeps pull requests read-only and every action pinned to a commit", () => {

View File

@ -0,0 +1,60 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listProjectWorkspaceFiles, readProjectWorkspaceFile } from "./project-workspace-files.js";
describe("project workspace files", () => {
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function linkedFolderBesideSecret() {
const base = mkdtempSync(path.join(tmpdir(), "project-workspace-files-"));
roots.push(base);
const project = path.join(base, "project");
const outside = path.join(base, "outside");
mkdirSync(path.join(project, "docs"), { recursive: true });
mkdirSync(outside);
writeFileSync(path.join(project, "docs", "readme.md"), "inside");
writeFileSync(path.join(outside, "secret.txt"), "outside");
return { project, outside };
}
it("lists and reads files inside the linked folder, including symlinks that stay inside", async () => {
const { project } = linkedFolderBesideSecret();
symlinkSync(path.join(project, "docs", "readme.md"), path.join(project, "readme-link.md"));
const listed = await listProjectWorkspaceFiles(project, "project");
expect(listed.map((entry) => entry.path).sort()).toEqual([
"project/docs",
"project/readme-link.md",
]);
expect(await listProjectWorkspaceFiles(project, "project/docs")).toEqual([
{ path: "project/docs/readme.md", kind: "file", size: 6 },
]);
const read = await readProjectWorkspaceFile(project, "project/readme-link.md");
expect(new TextDecoder().decode(read)).toBe("inside");
});
it("keeps symlinks from leaving the linked folder", async () => {
const { project, outside } = linkedFolderBesideSecret();
symlinkSync(outside, path.join(project, "escape"));
symlinkSync(path.join(outside, "secret.txt"), path.join(project, "secret-link.txt"));
symlinkSync(path.join(project, "missing"), path.join(project, "dangling"));
const listed = await listProjectWorkspaceFiles(project, "project");
expect(listed.map((entry) => entry.path)).toEqual(["project/docs"]);
await expect(listProjectWorkspaceFiles(project, "project/escape")).rejects.toThrow(/escapes/i);
await expect(readProjectWorkspaceFile(project, "project/escape/secret.txt")).rejects.toThrow(
/escapes/i,
);
await expect(readProjectWorkspaceFile(project, "project/secret-link.txt")).rejects.toThrow(
/escapes/i,
);
});
});

View File

@ -1,6 +1,8 @@
import { readdir, readFile, stat } from "node:fs/promises"; import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import type { ComputerFileEntry } from "@rakazo/adapter-kit"; import type { ComputerFileEntry } from "@rakazo/adapter-kit";
import { normalizeWorkspacePath } from "./computer-support.js"; import { normalizeWorkspacePath } from "./computer-support.js";
import { isAllowedDesktopPath } from "./desktop-sandbox-paths.js";
import { import {
isProjectWorkspacePath, isProjectWorkspacePath,
projectWorkspaceRelativePath, projectWorkspaceRelativePath,
@ -14,28 +16,34 @@ export async function listProjectWorkspaceFiles(
if (!isProjectWorkspacePath(storedPath)) { if (!isProjectWorkspacePath(storedPath)) {
throw new Error("Path is not under the project workspace"); throw new Error("Path is not under the project workspace");
} }
const hostDirectory = resolveProjectHostPath(workspaceRoot, storedPath); const root = await realpath(workspaceRoot);
const hostDirectory = await containedHostPath(
root,
resolveProjectHostPath(workspaceRoot, storedPath),
);
const relativeDirectory = projectWorkspaceRelativePath(storedPath); const relativeDirectory = projectWorkspaceRelativePath(storedPath);
const entries = await readdir(hostDirectory, { withFileTypes: true }); const entries = await readdir(hostDirectory, { withFileTypes: true });
const listed = await Promise.all( const listed = await Promise.all(
entries.map(async (entry) => { entries.map(async (entry) => {
const childHost = resolveProjectHostPath( // A symlink that leaves the linked folder, or leads nowhere, is not part of the project.
workspaceRoot, const info = await containedHostPath(root, path.join(hostDirectory, entry.name))
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`, .then((target) => stat(target))
); .catch(() => null);
const info = await stat(childHost); if (!info) return [];
const childStored = normalizeWorkspacePath( const childStored = normalizeWorkspacePath(
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`, relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
); );
return { return [
{
path: childStored, path: childStored,
kind: info.isDirectory() ? ("dir" as const) : ("file" as const), kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
size: info.size, size: info.size,
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}), ...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
}; },
];
}), }),
); );
return listed; return listed.flat();
} }
export async function readProjectWorkspaceFile( export async function readProjectWorkspaceFile(
@ -43,10 +51,22 @@ export async function readProjectWorkspaceFile(
storedPath: string, storedPath: string,
options?: { maxBytes?: number }, options?: { maxBytes?: number },
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const hostPath = resolveProjectHostPath(workspaceRoot, storedPath); const hostPath = await containedHostPath(
await realpath(workspaceRoot),
resolveProjectHostPath(workspaceRoot, storedPath),
);
const info = await stat(hostPath); const info = await stat(hostPath);
if (options?.maxBytes !== undefined && info.size > options.maxBytes) { if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
throw new Error(`computer file exceeds ${options.maxBytes} bytes`); throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
} }
return new Uint8Array(await readFile(hostPath)); return new Uint8Array(await readFile(hostPath));
} }
/** Follows symlinks, but only to places inside the linked folder. */
async function containedHostPath(root: string, candidate: string): Promise<string> {
const target = await realpath(candidate);
if (!isAllowedDesktopPath(target, [root])) {
throw new Error("Path escapes the project workspace");
}
return target;
}

View File

@ -1,19 +1,10 @@
// Server-only. Kept out of the package index so browser bundles never load `node:child_process`.
import { spawn } from "node:child_process";
import { access } from "node:fs/promises"; import { access } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js"; import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js"; import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
// Lazy import to avoid bundling `node:child_process` in browser bundles.
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
type SpawnFn = typeof import("node:child_process").spawn;
let _spawn: SpawnFn | undefined;
const getSpawn = (): SpawnFn => {
if (_spawn === undefined) {
_spawn = require("node:child_process").spawn as SpawnFn;
}
return _spawn;
};
export interface GitCheckoutStatus { export interface GitCheckoutStatus {
present: boolean; present: boolean;
remoteUrl: string | null; remoteUrl: string | null;
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> { export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const spawn = getSpawn();
const child = spawn("git", args, { cwd, env: process.env }); const child = spawn("git", args, { cwd, env: process.env });
let output = ""; let output = "";
child.stdout.on("data", (chunk: Buffer | string) => { child.stdout.on("data", (chunk: Buffer | string) => {

View File

@ -1,12 +1,12 @@
import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "./client.js"; import type { PrismaClient } from "./client.js";
import { import {
assertProjectWorkspaceDirectory,
InvalidProjectWorkspacePathError, InvalidProjectWorkspacePathError,
normalizeProjectWorkspacePath, normalizeProjectWorkspacePath,
resolveProjectWorkspaceDirectory,
wireProjectWorkspace, wireProjectWorkspace,
} from "./project-workspace.js"; } from "./project-workspace.js";
@ -43,11 +43,21 @@ describe("project workspace wiring", () => {
it("rejects missing folders", async () => { it("rejects missing folders", async () => {
const missing = path.join(tmpdir(), `missing-${Date.now()}`); const missing = path.join(tmpdir(), `missing-${Date.now()}`);
await expect(assertProjectWorkspaceDirectory(missing)).rejects.toBeInstanceOf( await expect(resolveProjectWorkspaceDirectory(missing)).rejects.toBeInstanceOf(
InvalidProjectWorkspacePathError, InvalidProjectWorkspacePathError,
); );
}); });
it("resolves a symlinked folder to the real folder behind it", async () => {
const root = tempDir("project-workspace-symlink-");
const real = path.join(root, "real");
mkdirSync(real);
symlinkSync(real, path.join(root, "link"));
expect(await resolveProjectWorkspaceDirectory(path.join(root, "link"))).toBe(
realpathSync(real),
);
});
it("persists workspace and git metadata on the space", async () => { it("persists workspace and git metadata on the space", async () => {
const root = tempDir("project-workspace-wire-"); const root = tempDir("project-workspace-wire-");
mkdirSync(path.join(root, ".git")); mkdirSync(path.join(root, ".git"));
@ -64,7 +74,7 @@ describe("project workspace wiring", () => {
spaceId: "space-1", spaceId: "space-1",
workspacePath: root, workspacePath: root,
}); });
expect(wired.path).toBe(path.resolve(root)); expect(wired.path).toBe(realpathSync(root));
expect(wired.git).toEqual({ expect(wired.git).toEqual({
remoteUrl: "https://github.com/example/demo.git", remoteUrl: "https://github.com/example/demo.git",
defaultBranch: "main", defaultBranch: "main",
@ -73,7 +83,7 @@ describe("project workspace wiring", () => {
dirtyPaths: [], dirtyPaths: [],
}); });
expect(updates[0]).toMatchObject({ expect(updates[0]).toMatchObject({
workspacePath: path.resolve(root), workspacePath: realpathSync(root),
gitRemoteUrl: "https://github.com/example/demo.git", gitRemoteUrl: "https://github.com/example/demo.git",
gitDefaultBranch: "main", gitDefaultBranch: "main",
gitHeadCommit: "abc123", gitHeadCommit: "abc123",

View File

@ -1,4 +1,4 @@
import { access, stat } from "node:fs/promises"; import { realpath, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { readGitCheckout } from "@rakazo/core/git-checkout"; import { readGitCheckout } from "@rakazo/core/git-checkout";
import type { PrismaClient } from "./client.js"; import type { PrismaClient } from "./client.js";
@ -22,11 +22,13 @@ export function normalizeProjectWorkspacePath(input: string): string {
return path.resolve(trimmed); return path.resolve(trimmed);
} }
export async function assertProjectWorkspaceDirectory(workspacePath: string): Promise<void> { /** The real folder behind a path, so a symlink cannot later point the link somewhere else. */
const resolved = normalizeProjectWorkspacePath(workspacePath); export async function resolveProjectWorkspaceDirectory(workspacePath: string): Promise<string> {
const normalized = normalizeProjectWorkspacePath(workspacePath);
let resolved: string;
let info: Awaited<ReturnType<typeof stat>>; let info: Awaited<ReturnType<typeof stat>>;
try { try {
await access(resolved); resolved = await realpath(normalized);
info = await stat(resolved); info = await stat(resolved);
} catch { } catch {
throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable."); throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable.");
@ -34,6 +36,7 @@ export async function assertProjectWorkspaceDirectory(workspacePath: string): Pr
if (!info.isDirectory()) { if (!info.isDirectory()) {
throw new InvalidProjectWorkspacePathError("That path is not a folder."); throw new InvalidProjectWorkspacePathError("That path is not a folder.");
} }
return resolved;
} }
export interface WiredProjectWorkspace { export interface WiredProjectWorkspace {
@ -52,8 +55,7 @@ export async function wireProjectWorkspace(
prisma: Pick<PrismaClient, "space">, prisma: Pick<PrismaClient, "space">,
input: { spaceId: string; workspacePath: string }, input: { spaceId: string; workspacePath: string },
): Promise<WiredProjectWorkspace> { ): Promise<WiredProjectWorkspace> {
const resolved = normalizeProjectWorkspacePath(input.workspacePath); const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath);
await assertProjectWorkspaceDirectory(resolved);
const git = await readGitCheckout(resolved); const git = await readGitCheckout(resolved);
await prisma.space.update({ await prisma.space.update({

View File

@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters"; import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
@ -2185,6 +2185,100 @@ describeWithDatabase("API authorization and resource isolation", () => {
}); });
} }
}); });
it("lets only the deployment owner link a host folder and read its files", async () => {
const owner = await signup(app, `folder-owner-${stamp}@rakazo.test`, "Folder Owner");
const member = await signup(app, `folder-member-${stamp}@rakazo.test`, "Folder Member");
const ownerActor = await rpc<Actor>(app, owner, "me");
const settings = await handles.prisma.deploymentSettings.findUniqueOrThrow({
where: { id: "default" },
select: { ownerUserId: true },
});
await handles.prisma.deploymentSettings.update({
where: { id: "default" },
data: { ownerUserId: ownerActor.userId },
});
// Stands in for any folder the API process can read, such as its own checkout.
const hostFolder = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-authz-host-")));
writeFileSync(path.join(hostFolder, ".env"), "HOST_SECRET=not-a-real-secret\n");
try {
await expectForbidden(app, member, "spaces/create", {
name: "Member folder",
workspacePath: hostFolder,
});
const memberNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
expect(memberNavigation.spaces.map((space) => space.name)).not.toContain("Member folder");
// A space linked before only the owner could link stays closed to the member.
const legacy = await rpc<Space>(app, member, "spaces/create", { name: "Legacy folder" });
await handles.prisma.space.update({
where: { id: legacy.id },
data: { workspacePath: hostFolder },
});
const memberBot = await rpc<Bot>(
app,
member,
"bots/create",
botInput("Member Folder Reader"),
legacy.id,
);
await expectForbidden(
app,
member,
"computer/files",
{ botId: memberBot.id, path: "project" },
legacy.id,
);
await expectForbidden(
app,
member,
"computer/readFile",
{ botId: memberBot.id, path: "project/.env" },
legacy.id,
);
const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull();
const linked = await rpc<Space>(app, owner, "spaces/create", {
name: "Owner folder",
workspacePath: hostFolder,
});
expect(linked.workspace?.path).toBe(hostFolder);
const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list");
expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe(
hostFolder,
);
const ownerBot = await rpc<Bot>(
app,
owner,
"bots/create",
botInput("Owner Folder Reader"),
linked.id,
);
const listing = await rpc<Array<{ path: string }>>(
app,
owner,
"computer/files",
{ botId: ownerBot.id, path: "project" },
linked.id,
);
expect(listing.map((entry) => entry.path)).toContain("project/.env");
const file = await rpc<{ content: string }>(
app,
owner,
"computer/readFile",
{ botId: ownerBot.id, path: "project/.env" },
linked.id,
);
expect(file.content).toContain("HOST_SECRET");
} finally {
await handles.prisma.deploymentSettings.update({
where: { id: "default" },
data: { ownerUserId: settings.ownerUserId },
});
rmSync(hostFolder, { recursive: true, force: true });
}
});
}); });
function botInput(name: string) { function botInput(name: string) {