instar 1.3.492 → 1.3.494

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dashboard/index.html +126 -26
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +3 -0
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  6. package/dist/core/PostUpdateMigrator.js +17 -0
  7. package/dist/core/PostUpdateMigrator.js.map +1 -1
  8. package/dist/core/RemoteCloseAudit.d.ts +34 -0
  9. package/dist/core/RemoteCloseAudit.d.ts.map +1 -0
  10. package/dist/core/RemoteCloseAudit.js +47 -0
  11. package/dist/core/RemoteCloseAudit.js.map +1 -0
  12. package/dist/core/SessionManager.d.ts +4 -0
  13. package/dist/core/SessionManager.d.ts.map +1 -1
  14. package/dist/core/SessionManager.js +1 -1
  15. package/dist/core/SessionManager.js.map +1 -1
  16. package/dist/messaging/slack/SocketModeClient.d.ts +10 -0
  17. package/dist/messaging/slack/SocketModeClient.d.ts.map +1 -1
  18. package/dist/messaging/slack/SocketModeClient.js +77 -49
  19. package/dist/messaging/slack/SocketModeClient.js.map +1 -1
  20. package/dist/monitoring/ReapLog.d.ts +6 -0
  21. package/dist/monitoring/ReapLog.d.ts.map +1 -1
  22. package/dist/monitoring/ReapLog.js +1 -0
  23. package/dist/monitoring/ReapLog.js.map +1 -1
  24. package/dist/scaffold/templates.d.ts.map +1 -1
  25. package/dist/scaffold/templates.js +1 -0
  26. package/dist/scaffold/templates.js.map +1 -1
  27. package/dist/server/CapabilityIndex.js +1 -1
  28. package/dist/server/CapabilityIndex.js.map +1 -1
  29. package/dist/server/routes.d.ts.map +1 -1
  30. package/dist/server/routes.js +112 -0
  31. package/dist/server/routes.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/data/builtin-manifest.json +64 -64
  34. package/src/data/state-coherence-registry.json +12 -0
  35. package/src/scaffold/templates.ts +1 -0
  36. package/upgrades/1.3.493.md +33 -0
  37. package/upgrades/1.3.494.md +27 -0
  38. package/upgrades/side-effects/remote-session-close.md +85 -0
  39. package/upgrades/side-effects/socketmode-ws-leak-fix.md +74 -0
@@ -36,6 +36,7 @@ import { readGuardPostureBootSnapshot, resolveGuardConfigSnapshot, } from '../mo
36
36
  import { buildGuardInventory } from '../monitoring/guardPostureView.js';
37
37
  import { GuardRegistry } from '../monitoring/GuardRegistry.js';
38
38
  import { isPeerUrlAllowedForCredentials } from './peerUrlGuard.js';
39
+ import { RemoteCloseAudit } from '../core/RemoteCloseAudit.js';
39
40
  import { FailureLedger } from '../monitoring/FailureLedger.js';
40
41
  import { FailureAttributionEngine } from '../monitoring/FailureAttributionEngine.js';
41
42
  import { FailureAnalyzer } from '../monitoring/FailureAnalyzer.js';
@@ -4589,6 +4590,11 @@ export function createRoutes(ctx) {
4589
4590
  if (selfMachineNickname)
4590
4591
  result.machineNickname = selfMachineNickname;
4591
4592
  }
4593
+ // Informed-consent input for the dashboard close confirm (REMOTE-SESSION-
4594
+ // CLOSE-SPEC §2.2): protected status was previously a config-side
4595
+ // membership test evaluated only inside terminateSession on the owner —
4596
+ // invisible to any UI. Additive; flows through the pool fan-out unchanged.
4597
+ result.protected = (ctx.config.sessions?.protectedSessions ?? []).includes(s.tmuxSession);
4592
4598
  // Add hook event telemetry
4593
4599
  if (req.query.enrich !== 'false' && ctx.hookEventReceiver) {
4594
4600
  const summary = ctx.hookEventReceiver.getSessionSummary(s.tmuxSession);
@@ -5245,9 +5251,16 @@ export function createRoutes(ctx) {
5245
5251
  res.status(404).json({ error: `Session "${req.params.id}" not found` });
5246
5252
  return;
5247
5253
  }
5254
+ // UNTRUSTED provenance claim (REMOTE-SESSION-CLOSE-SPEC §2.3): any token
5255
+ // holder could set this header. Recorded in the reap-log as `viaClaim`
5256
+ // (a signal in the audit trail); NEVER consulted in authority decisions —
5257
+ // those read the route-stamped `origin` below. Sanitized + bounded.
5258
+ const viaHeader = req.headers['x-instar-close-via'];
5259
+ const via = typeof viaHeader === 'string' && /^[a-z0-9-]{1,40}$/.test(viaHeader) ? viaHeader : undefined;
5248
5260
  const result = await ctx.sessionManager.terminateSession(target.id, 'operator-kill', {
5249
5261
  origin: 'operator',
5250
5262
  finalStatus: 'killed',
5263
+ ...(via ? { via } : {}),
5251
5264
  });
5252
5265
  if (!result.terminated) {
5253
5266
  res.status(404).json({ error: `Session "${req.params.id}" not found`, skipped: result.skipped });
@@ -5259,6 +5272,105 @@ export function createRoutes(ctx) {
5259
5272
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
5260
5273
  }
5261
5274
  });
5275
+ // ── Remote Session Close (REMOTE-SESSION-CLOSE-SPEC) ────────────────
5276
+ // Relay an OPERATOR-origin close to the machine that owns the session —
5277
+ // the same kill the dashboard already produces locally, executed on the
5278
+ // owner (§2.0: no new authority class; reach expands, which is why the
5279
+ // rate limit + URL allowlist + relay-side audit below are load-bearing).
5280
+ // A NEW path by design: a pre-feature server 404s it cleanly, so a
5281
+ // mixed-version pool can never misroute this into a LOCAL same-named kill
5282
+ // (§2.1 — the wrong-machine-kill shape the query-param design had).
5283
+ const remoteCloseLimiter = rateLimiter(60_000, 10);
5284
+ const remoteCloseAudit = new RemoteCloseAudit(ctx.config.stateDir);
5285
+ router.post('/sessions/:name/remote-close', remoteCloseLimiter, async (req, res) => {
5286
+ const { machineId, sessionUuid } = (req.body ?? {});
5287
+ // machineId is a registry LOOKUP KEY ONLY — charset-clamped, never used
5288
+ // to construct a URL (§2.1; closes the SSRF-with-credentials shape).
5289
+ if (typeof machineId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(machineId)) {
5290
+ res.status(404).json({ error: 'unknown machine' });
5291
+ return;
5292
+ }
5293
+ if (typeof sessionUuid !== 'string' || !/^[A-Za-z0-9-]{1,64}$/.test(sessionUuid)) {
5294
+ res.status(400).json({ error: 'sessionUuid required' });
5295
+ return;
5296
+ }
5297
+ const sessionName = SESSION_NAME_RE.test(req.params.name) ? req.params.name : undefined;
5298
+ const machine = (ctx.listPoolMachines?.() ?? []).find((m) => m.machineId === machineId);
5299
+ if (!machine || !machine.lastKnownUrl) {
5300
+ // Unknown or unresolvable machine: 404 with ZERO outbound request.
5301
+ res.status(404).json({ error: 'unknown machine', machineId });
5302
+ return;
5303
+ }
5304
+ const nickname = ctx.machinePoolRegistry?.getCapacity(machineId)?.nickname ?? machine.nickname;
5305
+ const extraAllowlist = ctx.config.multiMachine
5306
+ ?.peerUrlAllowlist;
5307
+ if (!isPeerUrlAllowedForCredentials(machine.lastKnownUrl, extraAllowlist).ok) {
5308
+ // The pool Bearer token NEVER travels to a non-allowlisted URL —
5309
+ // stronger here than on the read fan-out because this verb is
5310
+ // destructive (§2.1 shipping dependency).
5311
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'url-rejected' });
5312
+ res.status(502).json({ error: 'url-rejected', machineId });
5313
+ return;
5314
+ }
5315
+ try {
5316
+ // The peer's PLAIN local close — no relay params forwarded (single-hop
5317
+ // by construction, §2.1); UUID-targeted (the peer's existing route is
5318
+ // UUID-first, so on a ghost-bearing peer this closes exactly the tile
5319
+ // the operator saw). The via header is the UNTRUSTED §2.3 claim the
5320
+ // peer records as viaClaim.
5321
+ const r = await fetch(`${machine.lastKnownUrl}/sessions/${encodeURIComponent(sessionUuid)}`, {
5322
+ method: 'DELETE',
5323
+ headers: {
5324
+ Authorization: `Bearer ${ctx.config.authToken}`,
5325
+ 'X-Instar-Close-Via': 'remote-dashboard',
5326
+ },
5327
+ signal: AbortSignal.timeout(5000),
5328
+ });
5329
+ let peerBody = null;
5330
+ try {
5331
+ peerBody = (await r.json());
5332
+ }
5333
+ catch {
5334
+ // @silent-fallback-ok — a non-JSON body (Cloudflare 502/530 HTML
5335
+ // error page) is normalized below so the dashboard's resp.json()
5336
+ // never throws into a reasonless "Network error" (§2.2).
5337
+ peerBody = null;
5338
+ }
5339
+ if (r.ok) {
5340
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'closed', peerStatus: r.status });
5341
+ res.json({ ok: true, killed: sessionUuid, machineId, nickname });
5342
+ return;
5343
+ }
5344
+ if (r.status === 404) {
5345
+ // Idempotent skip / already gone / just won by another viewer —
5346
+ // a CALM already-closed outcome, not a scary error (§2.3).
5347
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'already-closed', peerStatus: 404 });
5348
+ res.json({ ok: true, alreadyClosed: true, machineId, nickname });
5349
+ return;
5350
+ }
5351
+ if (r.status === 401 || r.status === 403) {
5352
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'unauthorized', peerStatus: r.status });
5353
+ res.status(502).json({ error: `unauthorized by ${nickname ?? machineId}` });
5354
+ return;
5355
+ }
5356
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'peer-error', peerStatus: r.status });
5357
+ res.status(502).json({
5358
+ error: typeof peerBody?.error === 'string' ? peerBody.error : `${r.status} from ${nickname ?? machineId}`,
5359
+ });
5360
+ }
5361
+ catch (err) {
5362
+ const name = err instanceof Error ? err.name : '';
5363
+ if (name === 'TimeoutError' || name === 'AbortError') {
5364
+ // Delivery honesty (§2.3): the peer may be mid-kill under the same
5365
+ // load that made the tile stale. UNKNOWN, never "closed nothing".
5366
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'unknown' });
5367
+ res.status(504).json({ error: 'outcome unknown — peer did not answer in time', outcomeUnknown: true, machineId, nickname });
5368
+ return;
5369
+ }
5370
+ remoteCloseAudit.record({ targetMachineId: machineId, targetNickname: nickname, sessionUuid, sessionName, outcome: 'unreachable' });
5371
+ res.status(502).json({ error: `unreachable: ${nickname ?? machineId}` });
5372
+ }
5373
+ });
5262
5374
  // Create an interactive session from the dashboard.
5263
5375
  // Set platform to 'telegram', 'slack', or 'headless'.
5264
5376
  // Legacy: headless=true is equivalent to platform='headless'.