diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index c9c3c7e..c7ee1f7 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -694,7 +694,8 @@ public final class FleetApp { if (!wait) { // Fire-and-poll (CB-107): return a ticket immediately; the caller polls GET /tasks/{ticket}. - String ticket = messages.sendAsync(id, content); + Principal caller = ctx.attribute(CALLER); + String ticket = messages.sendAsync(id, content, null, caller == null ? null : caller.terminal()); ctx.status(202).json(Map.of("sessionId", id, "ticket", ticket, "status", "accepted")); return; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java index 3cadf92..2b93b4e 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -214,6 +214,44 @@ class FleetAppAuthTest { } } + /** + * {@code POST /sessions/{id}/message} with {@code wait:false} must record the creating + * caller's own terminal on the ticket it returns, so that caller can still poll its own + * ticket over REST, while a different terminal is refused. + */ + @Test + void restSendAsyncRecordsTheCreatingCallersTerminalSoItCanStillPollItsOwnTicket() throws Exception { + FakeHerdr herdr = new FakeHerdr(); + AgentControl agents = new AgentControl(herdr); + Injector injector = new Injector(agents); + MessageService messages = new MessageService(agents, injector, new Rendezvous()); + + Javalin leadApp = startOnSharedService(messages, herdr, FakeHerdr.WORKER_PID, Map.of("term_a", "lead-x")); + Javalin otherWorkerApp = startOnSharedService(messages, herdr, 9001L); // -> term_shell + try { + ObjectMapper mapper = new ObjectMapper(); + HttpResponse created = send(leadApp.port(), "POST", "/sessions/term_a/message", + "{\"content\":\"long task\",\"wait\":false}", null); + assertEquals(202, created.statusCode(), created.body()); + String ticket = mapper.readTree(created.body()).path("ticket").asText(null); + assertNotNull(ticket, "the accepted response carried no ticket: " + created.body()); + + HttpResponse own = send(leadApp.port(), "GET", "/tasks/" + ticket, null, null); + assertEquals(200, own.statusCode()); + assertFalse(own.body().contains("forbidden"), + "the session that created the ticket over REST must be able to poll it: " + own.body()); + + HttpResponse refused = send(otherWorkerApp.port(), "GET", "/tasks/" + ticket, null, null); + assertEquals(200, refused.statusCode()); + assertTrue(refused.body().contains("forbidden: this ticket was created by a different session"), + "a different terminal must still be refused with the ownership detail, not some " + + "other rejection: " + refused.body()); + } finally { + leadApp.stop(); + otherWorkerApp.stop(); + } + } + /** * As {@link #start}, but shares {@code messages} and {@code herdr} across several app * instances bound to different pids, each returned as its own started {@link Javalin} rather @@ -221,6 +259,16 @@ class FleetAppAuthTest { * poll the same ticket. */ private Javalin startOnSharedService(MessageService messages, FakeHerdr herdr, long pid) { + return startOnSharedService(messages, herdr, pid, Map.of()); + } + + /** + * As {@link #startOnSharedService(MessageService, FakeHerdr, long)}, but {@code leadTerminals} + * resolves the given pid's terminal to a named lead (a caller with SEND permission) instead of + * a plain worker, for a test that needs a terminal-bearing caller able to create a ticket. + */ + private Javalin startOnSharedService(MessageService messages, FakeHerdr herdr, long pid, + Map leadTerminals) { FleetConfig.Profile wcfg = new FleetConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); @@ -232,7 +280,7 @@ class FleetAppAuthTest { SessionManager sessions = new SessionManager(workers, new FakeWorktrees()); ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid); CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, false, null, - Map::of, new MemberRegistry(null)); + () -> leadTerminals, new MemberRegistry(null)); Metrics appMetrics = FleetMetrics.create(sessions, new dev.ltms.fleet.msg.InMemoryReplyInbox()); return new FleetApp(herdr, workers, sessions, messages, sessions.asPresence(), null,