mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
fix(computers): allocate listeners outside guest ephemeral ports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
66028796f3
commit
cfa2b81539
6 files changed
+112
-4
No files matched your search
@@ -4,6 +4,7 @@ import type {
|
||||
Ledger,
|
||||
Owner,
|
||||
ProcessClaim,
|
||||
RunnerPortAvailability,
|
||||
} from "../domain/ledger.js";
|
||||
export interface ComputerRepository {
|
||||
create(record: ComputerRecord): Promise<void>;
|
||||
@@ -31,6 +32,7 @@ export interface ComputerBackend {
|
||||
ready(record: ComputerRecord): Promise<void>;
|
||||
claim(record: ComputerRecord): Promise<void>;
|
||||
advance(record: ComputerRecord, owner: Owner): Promise<void>;
|
||||
runnerPorts(record: ComputerRecord): Promise<RunnerPortAvailability>;
|
||||
runner(record: ComputerRecord, options?: { control?: boolean }): Promise<CommandManagedRuntimeRunner>;
|
||||
launch(
|
||||
record: ComputerRecord,
|
||||
|
||||
@@ -50,6 +50,7 @@ function fixture() {
|
||||
}),
|
||||
claim: vi.fn(async () => {}),
|
||||
advance: vi.fn(async () => {}),
|
||||
runnerPorts: vi.fn(async () => ({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [] })),
|
||||
runner: vi.fn(async () => ({ execute: vi.fn() })),
|
||||
launch: vi.fn(async (_record, owner) => ({
|
||||
...owner.process!,
|
||||
@@ -114,6 +115,43 @@ function fixture() {
|
||||
};
|
||||
}
|
||||
describe("computer ownership", () => {
|
||||
it("allocates outside guest ephemeral and occupied ports while reserving live owners", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
vi.mocked(f.backend.runnerPorts).mockResolvedValue({
|
||||
ephemeralStart: 16127, ephemeralEnd: 16128, occupied: [16129, 43130],
|
||||
});
|
||||
const first = await f.admit();
|
||||
const second = await f.admit("other-agent", "other-session");
|
||||
expect(first.listenerPort).toBe(16130);
|
||||
expect(second.listenerPort).toBe(16131);
|
||||
expect(f.backend.advance).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ port: 16131 }));
|
||||
for (const port of [first.listenerPort, 43130]) {
|
||||
await expect(f.service.preview({ ...f.scope, owner: first.owner, port })).rejects.toMatchObject({ code: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when the guest reserves the entire fixed listener range", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
vi.mocked(f.backend.runnerPorts).mockResolvedValue({ ephemeralStart: 1024, ephemeralEnd: 65535, occupied: [] });
|
||||
await expect(f.admit()).rejects.toMatchObject({ code: "conflict", message: "Computer has no available runner ports" });
|
||||
expect(f.backend.advance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves historical warm listener identity without reallocating an occupied own port", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
const first = await f.admit();
|
||||
await first.launch({ command: "runnerd" });
|
||||
await f.repository.update(f.scope, record => { record.ledger.owners.find(owner => owner.id === first.owner.ownerId)!.port = 43130; });
|
||||
await f.service.retainWarm({ ...f.scope, owner: first.owner, idleTimeoutMs: 60_000 });
|
||||
vi.mocked(f.backend.runnerPorts).mockClear();
|
||||
const next = await f.admit();
|
||||
expect(next.listenerPort).toBe(43130);
|
||||
expect(f.backend.runnerPorts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes checkout credentials transiently without retaining them in computer state", async () => {
|
||||
const f = fixture();
|
||||
await f.attach();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expired,
|
||||
liveOwners,
|
||||
nextPort,
|
||||
isRunnerPort,
|
||||
segment,
|
||||
timeout,
|
||||
type ComputerRecord,
|
||||
@@ -455,6 +456,18 @@ finally:
|
||||
});
|
||||
await backend.ready(result.record);
|
||||
await backend.claim(result.record);
|
||||
if (result.owner.generation === 1 && !result.owner.process) {
|
||||
const availability = await backend.runnerPorts(result.record);
|
||||
const allocated = await repository.update(input, (record) => {
|
||||
const owner = exactOwner(record, ref(result.record, result.owner));
|
||||
if (owner.phase !== "starting")
|
||||
throw new ComputerError("conflict", "Computer admission was retired");
|
||||
owner.port = nextPort(record.ledger.owners.filter((other) => other.id !== owner.id), availability);
|
||||
return { record: structuredClone(record), owner: structuredClone(owner) };
|
||||
});
|
||||
result.record = allocated.record;
|
||||
result.owner = allocated.owner;
|
||||
}
|
||||
await backend.advance(result.record, result.owner);
|
||||
await backend.renew(result.record);
|
||||
if (input.probeId) {
|
||||
@@ -661,7 +674,7 @@ finally:
|
||||
expired(owner, now())
|
||||
)
|
||||
throw new ComputerError("conflict", "Preview owner is not live");
|
||||
if (input.port >= 43127 && input.port < 44127)
|
||||
if (isRunnerPort(input.port))
|
||||
throw new ComputerError(
|
||||
"forbidden",
|
||||
"Runner ports are not application previews",
|
||||
|
||||
Reference in new issue
Block a user