Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3a67b0c
feat(development): list-machines-in-a-drive query + shared session le…
2witstudios Jul 11, 2026
1faced8
Merge remote-tracking branch 'origin/master' into pu/development-surf…
2witstudios Jul 11, 2026
6eb062d
refactor(machine): TerminalTab consumes the extracted SessionLeaves
2witstudios Jul 11, 2026
c4f120f
feat(development): Development surface — nav entry, sidebar swap, agg…
2witstudios Jul 12, 2026
4a1625b
fix(development): admin-gate the surface; wait for drives before redi…
2witstudios Jul 12, 2026
9a8014d
refactor(development): drop a redundant callback; document the tab-fo…
2witstudios Jul 12, 2026
69e3cc9
Merge remote-tracking branch 'origin/master' into pu/development-surf…
2witstudios Jul 12, 2026
d9d8101
fix(development): keep terminals alive across machine switches
2witstudios Jul 12, 2026
3ae283d
fix(development): session clicks were silently dropped by a React lan…
2witstudios Jul 12, 2026
a0fb9f3
fix(development): expire session intents; honour fetch errors; make t…
2witstudios Jul 12, 2026
9ad364a
fix(development): make a session click actually reach the terminal
2witstudios Jul 12, 2026
1fa0f84
test(development): component tests for the sidebar; isolate the tab s…
2witstudios Jul 12, 2026
f3aee88
refactor(development): drop the unenforced TTL; harden the machine se…
2witstudios Jul 12, 2026
133d46b
refactor(development): derive the sticky machine set with the repo's …
2witstudios Jul 12, 2026
155313e
fix(development): a vanished machine stops being shown without evicti…
2witstudios Jul 12, 2026
7fec2ad
fix(development): make the machine list actually recover; fix a test …
2witstudios Jul 12, 2026
3ee53c5
fix(development): a failed poll must not tear down a working machine …
2witstudios Jul 12, 2026
043e5b1
fix(development): the last raw NUL byte — in the file that diagnosed …
2witstudios Jul 12, 2026
8c3b345
Merge remote-tracking branch 'origin/master' into pu/development-surf…
2witstudios Jul 12, 2026
67eb11f
fix(development): never open a session into a machine the host is kee…
2witstudios Jul 12, 2026
1102b3b
Merge remote-tracking branch 'origin/master' into pu/development-surf…
2witstudios Jul 12, 2026
b1a9810
polish(development): pin the positive half of the display gate; tidy …
2witstudios Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Source is always text, and must always diff as text.
#
# git classifies a blob as binary if it finds a NUL byte in the first 8k — and a
# stray NUL in a source file is easy to introduce (a NUL used as a string
# delimiter, pasted verbatim instead of escaped) and almost impossible to notice.
# The cost is severe and silent: `git diff` and GitHub render the whole file as
# "Bin N -> M bytes", so every change to it sails through review unseen, and it
# gets no three-way merge.
#
# This happened: MachineKeepAliveHost.tsx carried a literal NUL and was binary to
# git for its whole history. Forcing `diff` on source extensions makes that class
# of mistake cosmetic instead of review-defeating.
*.ts diff
*.tsx diff
*.js diff
*.jsx diff
*.mjs diff
*.cjs diff
*.json diff
*.css diff
*.md diff
97 changes: 97 additions & 0 deletions apps/web/src/app/api/machines/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Contract tests for GET /api/machines — the Development surface's
* list-machines-in-a-drive query.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';

const { mockAuthenticateRequest, mockIsAuthError, mockListDriveMachines, mockAuditRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockIsAuthError: vi.fn((result: unknown) => result != null && typeof result === 'object' && 'error' in result),
mockListDriveMachines: vi.fn(),
mockAuditRequest: vi.fn(),
}));

vi.mock('@/lib/auth', () => ({
authenticateRequestWithOptions: (...args: unknown[]) => mockAuthenticateRequest(...args),
isAuthError: (result: unknown) => mockIsAuthError(result),
}));

vi.mock('@pagespace/lib/audit/audit-log', () => ({
auditRequest: (...args: unknown[]) => mockAuditRequest(...args),
}));

vi.mock('@/lib/machines/machine-list-runtime', () => ({
listDriveMachines: (...args: unknown[]) => mockListDriveMachines(...args),
}));

import { GET } from '../route';

const AUTH_ADMIN = { userId: 'user-1', role: 'admin' };
const AUTH_NON_ADMIN = { userId: 'user-2', role: 'user' };
const AUTH_DENIED = { error: new Response(null, { status: 401 }) };

const MACHINE = { id: 'machine-1', title: 'Dev box', updatedAt: '2026-07-11T00:00:00.000Z' };

beforeEach(() => {
vi.clearAllMocks();
mockAuthenticateRequest.mockResolvedValue(AUTH_ADMIN);
mockListDriveMachines.mockResolvedValue([MACHINE]);
});

describe('GET /api/machines', () => {
it('refuses a non-admin, and never enumerates the drive for them', async () => {
// Machines are an app-admin feature: a non-admin who can merely VIEW a
// Machine page must not be able to enumerate the drive's machines (and,
// through the tree, their projects/branches/sessions).
mockAuthenticateRequest.mockResolvedValue(AUTH_NON_ADMIN);

const response = await GET(new Request('http://localhost/api/machines?driveId=drive-1'));

expect(response.status).toBe(403);
expect(mockListDriveMachines).not.toHaveBeenCalled();
});

it('audits the non-admin denial', async () => {
mockAuthenticateRequest.mockResolvedValue(AUTH_NON_ADMIN);

await GET(new Request('http://localhost/api/machines?driveId=drive-1'));

expect(mockAuditRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ eventType: 'authz.access.denied', userId: 'user-2' }),
);
});

it('returns the drive\'s machines for an admin', async () => {
const response = await GET(new Request('http://localhost/api/machines?driveId=drive-1'));

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ machines: [MACHINE] });
expect(mockListDriveMachines).toHaveBeenCalledWith('user-1', 'drive-1');
});

it('400s without a driveId', async () => {
const response = await GET(new Request('http://localhost/api/machines'));

expect(response.status).toBe(400);
expect(mockListDriveMachines).not.toHaveBeenCalled();
});

it('propagates the auth error and never touches the drive', async () => {
mockAuthenticateRequest.mockResolvedValue(AUTH_DENIED);

const response = await GET(new Request('http://localhost/api/machines?driveId=drive-1'));

expect(response.status).toBe(401);
expect(mockListDriveMachines).not.toHaveBeenCalled();
});

it('serves an empty list rather than 404 when the drive has no machines', async () => {
mockListDriveMachines.mockResolvedValue([]);

const response = await GET(new Request('http://localhost/api/machines?driveId=drive-1'));

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ machines: [] });
});
});
63 changes: 63 additions & 0 deletions apps/web/src/app/api/machines/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Machines API — the Development surface's aggregated tree needs the one thing
* no other machine route serves: every Machine in a drive, not one Machine by
* id.
*
* GET ?driveId=<id> → { machines: [{ id, title, updatedAt }] }
*
* Session-only (no MCP/agent tokens) — a human/UI surface, like the rest of
* `/api/machines/*`.
*
* App-admin only, matching the rest of the Machine feature: creating a MACHINE
* page requires `admin` (see POST /api/pages) and `MachineView` refuses to mount
* its tabs for anyone else. Without this, a non-admin drive member who can VIEW a
* Machine page could enumerate the drive's machines from the Development surface.
*
* Note this route is STRICTER than its siblings, not a system-wide guarantee:
* /api/machines/{projects,branches,agent-terminals} gate on `canViewMachine`, not
* on admin, so a non-admin with view access on a Machine page can still call them
* directly. This surface simply declines to be the thing that hands them the list
* of machines to call them with.
*
* Admin is necessary but not sufficient: the list is still filtered per page
* through `canUserViewPage`, so a Machine withheld from this admin by a
* page-level grant never appears.
*/

import { NextResponse } from 'next/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { auditRequest } from '@pagespace/lib/audit/audit-log';
import { loggers } from '@pagespace/lib/logging/logger-config';
import { listDriveMachines } from '@/lib/machines/machine-list-runtime';

const AUTH_OPTIONS_READ = { allow: ['session'] as const, requireCSRF: false };

export async function GET(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS_READ);
if (isAuthError(auth)) return auth.error;

const driveId = new URL(request.url).searchParams.get('driveId');
if (!driveId) {
return NextResponse.json({ error: 'driveId is required' }, { status: 400 });
}

if (auth.role !== 'admin') {
auditRequest(request, {
eventType: 'authz.access.denied',
userId: auth.userId,
resourceType: 'drive',
resourceId: driveId,
details: { reason: 'app_admin_required', method: 'GET', route: 'machines' },
riskScore: 0.5,
});
return NextResponse.json({ error: 'Machines require administrator privileges' }, { status: 403 });
}

try {
const machines = await listDriveMachines(auth.userId, driveId);
return NextResponse.json({ machines });
} catch (error) {
loggers.api.error('Error listing machines:', error as Error);
return NextResponse.json({ error: 'Failed to list machines' }, { status: 500 });
}
}
3 changes: 2 additions & 1 deletion apps/web/src/app/dashboard/DashboardLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const FULL_PAGE_ROUTES = [
'/dashboard/calendar',
'/dashboard/channels',
'/dashboard/connections',
'/dashboard/development',
'/dashboard/dms',
'/dashboard/drives',
'/dashboard/storage',
Expand All @@ -32,7 +33,7 @@ export default function DashboardLayoutClient({ children, nonce }: { children: R
// Also match /dashboard/[driveId]/activity pattern
const isFullPageRoute = FULL_PAGE_ROUTES.some(route =>
pathname === route || pathname?.startsWith(route + '/')
) || pathname?.match(/^\/dashboard\/[^/]+\/(activity|calendar|channels|files|tasks|trash|settings|members|workflows)/);
) || pathname?.match(/^\/dashboard\/[^/]+\/(activity|calendar|channels|development|files|tasks|trash|settings|members|workflows)/);


return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The Development surface's detail route.
*
* Renders NOTHING on purpose. The machine is drawn by `MachineKeepAliveHost` in
* this segment's layout, which keeps recently-visited machines mounted across
* navigation (CSS-hiding the inactive ones) so their terminals survive. Mounting
* a `MachineView` here as well would create a second, competing terminal subtree
* for the same machine — the same reason `CenterPanel` renders nothing for
* MACHINE pages in the drive view.
*
* The route still exists to make a machine bookmarkable: the URL is what the
* layout reads to decide which machine is active.
*/
export default function DevelopmentMachinePage() {
return null;
}
Loading