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.
- package/dashboard/index.html +126 -26
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +3 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +17 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/RemoteCloseAudit.d.ts +34 -0
- package/dist/core/RemoteCloseAudit.d.ts.map +1 -0
- package/dist/core/RemoteCloseAudit.js +47 -0
- package/dist/core/RemoteCloseAudit.js.map +1 -0
- package/dist/core/SessionManager.d.ts +4 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +1 -1
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/messaging/slack/SocketModeClient.d.ts +10 -0
- package/dist/messaging/slack/SocketModeClient.d.ts.map +1 -1
- package/dist/messaging/slack/SocketModeClient.js +77 -49
- package/dist/messaging/slack/SocketModeClient.js.map +1 -1
- package/dist/monitoring/ReapLog.d.ts +6 -0
- package/dist/monitoring/ReapLog.d.ts.map +1 -1
- package/dist/monitoring/ReapLog.js +1 -0
- package/dist/monitoring/ReapLog.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +1 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -1
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +112 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/data/state-coherence-registry.json +12 -0
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.493.md +33 -0
- package/upgrades/1.3.494.md +27 -0
- package/upgrades/side-effects/remote-session-close.md +85 -0
- package/upgrades/side-effects/socketmode-ws-leak-fix.md +74 -0
package/dist/server/routes.js
CHANGED
|
@@ -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'.
|