mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
fix(remote): authorize the attach wake first; tell the caller what happened to its bytes
Review round 3 on #439. - The attachRemoteSession branch of POST /api/sessions ran `ensureHostAwake` before the multi-user gates, so a non-admin could have any configured host's `wakeCommand` spawned (or a packet broadcast) and the request held for the wake budget, then be refused for the workingDir. The admin gate now comes first, before the host is even looked up; remote hosts are admin-only infrastructure everywhere else. Route test: wake spy empty, 403. - The non-wait input route answers `{buffered:true}` when the registry took the chunk and `{buffered:true, dropped:true}` when it was over the cap and is gone (`RemoteInputOutcome` gains 'dropped'); additive to the bare `{}`. - The send-and-wait path answers OPERATION_FAILED when the host never comes back, like create and attach, instead of writing into the stalled pane and reporting delivered:true plus a timeout. - The flush writes with `fromUser: true`, so a first prompt buffered through a wake can still name the tab. Docs: api-reference (input route), remote-sessions.md (two invariants), CLAUDE.md key pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
co-authored by
Claude Opus 5
parent
1040f6c489
commit
5bb489addb
@@ -370,6 +370,24 @@ describe('RemoteWakeRegistry', () => {
|
||||
expect(h.events).not.toContain('remote:sessionReconnected');
|
||||
});
|
||||
|
||||
it('reports an oversized chunk as dropped, and flushes as user input so the tab can be named', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
let release: (() => void) | undefined;
|
||||
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = () => resolve(true))));
|
||||
await expect(h.registry.handleInput(h.session, 'ok')).resolves.toBe('buffered');
|
||||
// Over the cap: never enters the buffer, and the caller is told — a bare 200 could
|
||||
// not distinguish delivered from buffered from gone.
|
||||
await expect(h.registry.handleInput(h.session, 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 1))).resolves.toBe(
|
||||
'dropped'
|
||||
);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(2);
|
||||
release?.();
|
||||
await h.registry.wake(h.session);
|
||||
// `fromUser`: a first prompt that was buffered through a wake may still name the tab.
|
||||
expect(h.writeViaMux).toHaveBeenCalledWith('ok', { fromUser: true });
|
||||
});
|
||||
|
||||
it('drops the buffer when a flush write fails, so nothing is replayed by a later wake', async () => {
|
||||
// Retaining the chunk was the earlier behaviour, and it was worse: the wake still
|
||||
// resolves and marks the host reachable, so the next input takes the deliver path
|
||||
|
||||
@@ -10,17 +10,42 @@
|
||||
* TCP connect, ssh, or WoL happens in CI.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, type RemoteWakeDeps } from '../../src/remote-wake.js';
|
||||
import type { SessionRemote } from '../../src/types.js';
|
||||
|
||||
const SESSION_ID = 'remote-wake-session';
|
||||
|
||||
/**
|
||||
* Mirror production's envelope + status mapping (as inbox-routes.test.ts does): a
|
||||
* returned `createErrorResponse` carries its 4xx, a plain object is wrapped in
|
||||
* `{success:true, data}`. Without it every error would read as a 200.
|
||||
*/
|
||||
function installEnvelope(app: FastifyInstance): void {
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
}
|
||||
const URL = `/api/sessions/${SESSION_ID}/input`;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -48,9 +73,23 @@ const remoteSession: SessionRemote = {
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
};
|
||||
|
||||
async function harness(opts: { remote?: SessionRemote; hostUp?: boolean; holdWake?: boolean } = {}): Promise<Harness> {
|
||||
async function harness(
|
||||
opts: {
|
||||
remote?: SessionRemote;
|
||||
hostUp?: boolean;
|
||||
holdWake?: boolean;
|
||||
/** Stands in for the auth middleware (multi-user mode); absent = synthetic admin. */
|
||||
authUser?: { username: string; role: 'admin' | 'user' };
|
||||
} = {}
|
||||
): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (opts.authUser) {
|
||||
const authUser = opts.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.remote = opts.remote ?? remoteSession;
|
||||
@@ -79,6 +118,7 @@ async function harness(opts: { remote?: SessionRemote; hostUp?: boolean; holdWak
|
||||
const registry = new RemoteWakeRegistry(deps);
|
||||
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installEnvelope(app);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() };
|
||||
@@ -95,7 +135,7 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
expect(res.json()).toEqual({ success: true, data: { buffered: true } });
|
||||
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
||||
@@ -133,7 +173,7 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.json()).toEqual({});
|
||||
expect(res.json()).toEqual({ success: true, data: {} }); // the historical bare answer, untouched
|
||||
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(session.reattachRemote).not.toHaveBeenCalled();
|
||||
@@ -248,3 +288,103 @@ describe('POST /api/sessions/:id/wake', () => {
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions + attachRemoteSession — authorization before the wake', () => {
|
||||
// Remote hosts are admin-only infra everywhere else, and the attach wake spawns the
|
||||
// host's `wakeCommand` (or broadcasts a packet). Before this gate a non-admin could
|
||||
// post an attach for any configured hostId, have that executable run and the request
|
||||
// held for the wake budget, and only THEN get a 403 for the workingDir (reproduced
|
||||
// upstream: wake spy fired once, response 403).
|
||||
it('403s a non-admin in multi-user mode without probing or waking the host', async () => {
|
||||
const prev = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
try {
|
||||
// `session-routes.ts` reads hosts from the sandboxed data dir (module-load-time
|
||||
// constant), so a host with a wake command is written THERE: a regression would
|
||||
// find it and fire the spy.
|
||||
await mkdir(getDataDir(), { recursive: true });
|
||||
await writeFile(
|
||||
join(getDataDir(), 'remote-hosts.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
},
|
||||
])
|
||||
);
|
||||
const h = await harness({ hostUp: false, authUser: { username: 'mallory', role: 'user' } });
|
||||
const res = await h.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().error).toMatch(/admin-only/);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(h.events).toEqual([]);
|
||||
await h.app.close();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input — what the caller is told', () => {
|
||||
it('says buffered, and dropped for a chunk over the wake buffer cap', async () => {
|
||||
// The non-wait branch always answered a bare `{}`; these fields are additive. Without
|
||||
// them a prompt over 4 KB posted to a sleeping host was accepted and silently lost.
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const small = await send(h.app, { input: 'hallo', useMux: true });
|
||||
expect(small.statusCode).toBe(200);
|
||||
expect(small.json()).toEqual({ success: true, data: { buffered: true } });
|
||||
|
||||
const big = await send(h.app, { input: 'x'.repeat(5000), useMux: true });
|
||||
expect(big.statusCode).toBe(200);
|
||||
expect(big.json()).toEqual({ success: true, data: { buffered: true, dropped: true } });
|
||||
expect(h.registry.pendingBytes(SESSION_ID)).toBe(5);
|
||||
h.releaseWake();
|
||||
await h.registry.wake(h.ctx.sessions.get(SESSION_ID)!);
|
||||
});
|
||||
|
||||
it('fails the send-and-wait path when the host never comes back, instead of writing into the stalled pane', async () => {
|
||||
// Readiness never arrives: the wake resolves false.
|
||||
const failing = await harnessWithFailingWake();
|
||||
const session = failing.ctx.sessions.get(SESSION_ID)!;
|
||||
const res = await send(failing.app, { input: 'hallo', useMux: true, wait: true, waitTimeout: 1000 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
expect(res.json().error).toMatch(/did not come back/);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
await failing.app.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** A harness whose readiness poll answers false: the wake command runs, the host stays down. */
|
||||
async function harnessWithFailingWake(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
ctx.sessions.get(SESSION_ID)!.remote = remoteSession;
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const events: string[] = [];
|
||||
const registry = new RemoteWakeRegistry({
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady: async () => false,
|
||||
delay: async () => {},
|
||||
noteReconnected: () => {},
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
});
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installEnvelope(app);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => {} };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user