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:
Randalix
2026-09-19 11:39:50 +02:00
co-authored by Claude Opus 5
parent 1040f6c489
commit 5bb489addb
7 changed files with 232 additions and 19 deletions
+18
View File
@@ -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