pilotswarm 0.5.13 → 0.5.15
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/README.md +6 -0
- package/mcp/README.md +14 -2
- package/mcp/dist/src/context.d.ts +13 -0
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +20 -0
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +5 -1
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/capabilities.d.ts +4 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +13 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/groups.d.ts +5 -4
- package/mcp/dist/src/tools/groups.d.ts.map +1 -1
- package/mcp/dist/src/tools/groups.js +43 -20
- package/mcp/dist/src/tools/groups.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +118 -2
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +3 -2
- package/tui/src/app.js +19 -2
- package/tui/src/auth/cli.js +13 -0
- package/tui/src/node-sdk-transport.js +99 -13
- package/tui/tui-splash-mobile.txt +5 -7
- package/tui/tui-splash.txt +13 -9
- package/ui/core/src/commands.js +2 -0
- package/ui/core/src/controller.js +454 -35
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +121 -8
- package/ui/core/src/selectors.js +204 -14
- package/ui/core/src/state.js +3 -0
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +798 -157
- package/web/api/router.js +7 -6
- package/web/api/ws.js +9 -0
- package/web/auth/index.js +5 -0
- package/web/auth/providers/dev.js +119 -0
- package/web/authz.js +142 -0
- package/web/dist/assets/index-CZizkB5Z.js +24 -0
- package/web/dist/assets/index-D9e2TGjO.css +1 -0
- package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
- package/web/dist/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +553 -37
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/index-oldX95Tp.css +0 -1
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
package/web/runtime.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { NodeSdkTransport } from "pilotswarm/host";
|
|
2
|
+
import {
|
|
3
|
+
loadAuthzConfig,
|
|
4
|
+
normalizeVisibility,
|
|
5
|
+
getMethodAccess,
|
|
6
|
+
evaluateSessionAccess,
|
|
7
|
+
relationFor,
|
|
8
|
+
forbiddenError,
|
|
9
|
+
notFoundError,
|
|
10
|
+
} from "./authz.js";
|
|
2
11
|
|
|
3
12
|
function normalizeParams(params) {
|
|
4
13
|
return params && typeof params === "object" ? params : {};
|
|
@@ -66,10 +75,13 @@ function normalizeOwnerPrincipal(principal) {
|
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
// Placement identity: group placements are keyed off the same principal as
|
|
79
|
+
// owner stamping. No-auth deployments fall back to a shared anonymous user
|
|
80
|
+
// (lazily registered by the placement proc) so grouping keeps working there.
|
|
81
|
+
function placementPrincipal(authContext) {
|
|
82
|
+
const principal = normalizeSessionOwner(authContext);
|
|
83
|
+
if (principal) return { provider: principal.provider, subject: principal.subject };
|
|
84
|
+
return { provider: "anonymous", subject: "anonymous" };
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
function requireUserPrincipal(authContext, methodName) {
|
|
@@ -82,12 +94,351 @@ function requireUserPrincipal(authContext, methodName) {
|
|
|
82
94
|
return principal;
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
// Break-glass audit coverage: every non-read session op, plus the reads that
|
|
98
|
+
// expose content (transcript, artifacts, history). Status polling and list
|
|
99
|
+
// metadata are excluded to keep the audit stream signal-dense.
|
|
100
|
+
const BREAK_GLASS_AUDITED = {
|
|
101
|
+
any: true,
|
|
102
|
+
getSessionEvents: true,
|
|
103
|
+
getSessionEventsBefore: true,
|
|
104
|
+
downloadArtifact: true,
|
|
105
|
+
readArtifactBase64: true,
|
|
106
|
+
getExecutionHistory: true,
|
|
107
|
+
getLatestResponse: true,
|
|
108
|
+
listSessionShares: true,
|
|
109
|
+
};
|
|
110
|
+
|
|
85
111
|
export class PortalRuntime {
|
|
86
112
|
constructor({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser } = {}) {
|
|
87
113
|
this.transport = new NodeSdkTransport({ store, mode, useManagedIdentity, cmsFactsDatabaseUrl, aadDbUser });
|
|
88
114
|
this.mode = mode;
|
|
89
115
|
this.started = false;
|
|
90
116
|
this.startPromise = null;
|
|
117
|
+
this.authz = loadAuthzConfig();
|
|
118
|
+
// Throttle repeated break-glass audit rows for the same actor+session
|
|
119
|
+
// (the portal polls events continuously while a session is open).
|
|
120
|
+
this._breakGlassSeen = new Map(); // key -> expiry epoch ms
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Authorization (security model) ──────────────────────────────────
|
|
124
|
+
|
|
125
|
+
_recordAudit(entry) {
|
|
126
|
+
if (typeof this.transport.recordAuthzAudit !== "function") return;
|
|
127
|
+
this.transport.recordAuthzAudit(entry).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_auditActor(authContext) {
|
|
131
|
+
const principal = authContext?.principal;
|
|
132
|
+
return {
|
|
133
|
+
provider: principal?.provider ?? null,
|
|
134
|
+
subject: principal?.subject ?? null,
|
|
135
|
+
display: principal?.displayName ?? principal?.email ?? null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_shouldRecordBreakGlass(actorKey, sessionId) {
|
|
140
|
+
const key = `${actorKey}${sessionId}`;
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const expiry = this._breakGlassSeen.get(key);
|
|
143
|
+
if (expiry && expiry > now) return false;
|
|
144
|
+
if (this._breakGlassSeen.size > 5000) this._breakGlassSeen.clear();
|
|
145
|
+
this._breakGlassSeen.set(key, now + 15 * 60 * 1000);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Gate one dispatched method. Returns { snapshot } (the access snapshot
|
|
151
|
+
* for session-scoped ops, so handlers can reuse it — e.g. sender
|
|
152
|
+
* relation) or throws 403/404. With enforcement off, would-be denials
|
|
153
|
+
* are audited and allowed through (dark launch).
|
|
154
|
+
*/
|
|
155
|
+
async _authorizeCall(method, safeParams, authContext, { owner, isAdmin }) {
|
|
156
|
+
const spec = getMethodAccess(method);
|
|
157
|
+
const access = spec?.access || "authed";
|
|
158
|
+
|
|
159
|
+
if (access === "authed" || access === "session:create" || access === "facts:read" || access === "group:list" || access === "session:list") {
|
|
160
|
+
// List/read scoping happens in the case handlers (viewer-scoped
|
|
161
|
+
// catalog paths); creation stamps owner+visibility there too.
|
|
162
|
+
return { snapshot: null };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (access === "fleet:read" || access === "fleet:admin") {
|
|
166
|
+
if (!isAdmin) {
|
|
167
|
+
const reason = access === "fleet:admin"
|
|
168
|
+
? "This operation requires the admin role."
|
|
169
|
+
: "Fleet-wide observability requires the admin role.";
|
|
170
|
+
this._recordAudit({
|
|
171
|
+
actor: this._auditActor(authContext),
|
|
172
|
+
action: method,
|
|
173
|
+
decision: this.authz.enforce ? "deny" : "would_deny",
|
|
174
|
+
reason,
|
|
175
|
+
});
|
|
176
|
+
if (this.authz.enforce || access === "fleet:admin") {
|
|
177
|
+
// fleet:admin has always been enforced (op.admin) —
|
|
178
|
+
// keep it hard regardless of the dark-launch flag.
|
|
179
|
+
throw forbiddenError(reason);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { snapshot: null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (access === "facts:write") {
|
|
186
|
+
await this._authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin });
|
|
187
|
+
return { snapshot: null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (access === "group:manage") {
|
|
191
|
+
await this._authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin });
|
|
192
|
+
return { snapshot: null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (access === "authz:audit") {
|
|
196
|
+
const sessionId = safeParams.sessionId ? String(safeParams.sessionId) : null;
|
|
197
|
+
if (isAdmin) return { snapshot: null };
|
|
198
|
+
if (!sessionId) throw forbiddenError("Fleet-wide audit requires the admin role. Pass sessionId to read audit for a session you own.");
|
|
199
|
+
// Owner-only, and hard-enforced (session:share) so a missing/deleted
|
|
200
|
+
// session id can't open the audit trail during dark-launch.
|
|
201
|
+
return this._gateSession(method, "session:share", sessionId, authContext, { owner, isAdmin });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (access === "session:copy") {
|
|
205
|
+
const [from, to] = await Promise.all([
|
|
206
|
+
this._gateSession(method, "session:read", safeParams.fromSessionId, authContext, { owner, isAdmin }),
|
|
207
|
+
this._gateSession(method, "session:write", safeParams.toSessionId, authContext, { owner, isAdmin }),
|
|
208
|
+
]);
|
|
209
|
+
return { snapshot: to.snapshot ?? from.snapshot };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (access.startsWith("session:")) {
|
|
213
|
+
const sessionId = safeParams[spec.sessionParam];
|
|
214
|
+
return this._gateSession(method, access, sessionId, authContext, { owner, isAdmin });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { snapshot: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Whether the deployment's transport can resolve access snapshots at all. */
|
|
221
|
+
_accessSnapshotSupported() {
|
|
222
|
+
return typeof this.transport.getSessionAccess === "function";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async _getAccessSnapshot(sessionId, owner) {
|
|
226
|
+
if (!sessionId || !this._accessSnapshotSupported()) return null;
|
|
227
|
+
return this.transport.getSessionAccess(String(sessionId), {
|
|
228
|
+
provider: owner?.provider ?? "",
|
|
229
|
+
subject: owner?.subject ?? "",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async _gateSession(method, accessClass, sessionId, authContext, { owner, isAdmin }) {
|
|
234
|
+
// session:share is a brand-new capability with no pre-model behavior
|
|
235
|
+
// to preserve, so it is enforced even during the ownership dark-launch
|
|
236
|
+
// — otherwise a user could pre-plant a durable grant that survives the
|
|
237
|
+
// flip to enforce (adversarial review HIGH-2).
|
|
238
|
+
const effectiveEnforce = this.authz.enforce || accessClass === "session:share";
|
|
239
|
+
const hasSessionId = sessionId != null && String(sessionId).trim() !== "";
|
|
240
|
+
|
|
241
|
+
// HIGH-1: a supplied-but-unresolvable id (missing OR soft-deleted —
|
|
242
|
+
// cms_get_session_access returns no row for either) must not open the
|
|
243
|
+
// gate. Only genuinely id-less ops get the permissive null path. Skip
|
|
244
|
+
// when the transport can't resolve snapshots at all (legacy/no-auth).
|
|
245
|
+
if (hasSessionId && this._accessSnapshotSupported()) {
|
|
246
|
+
const snapshot = await this._getAccessSnapshot(sessionId, owner);
|
|
247
|
+
if (!snapshot) {
|
|
248
|
+
this._recordAudit({
|
|
249
|
+
actor: this._auditActor(authContext),
|
|
250
|
+
action: method,
|
|
251
|
+
sessionId: String(sessionId),
|
|
252
|
+
decision: effectiveEnforce ? "deny" : "would_deny",
|
|
253
|
+
reason: "session not found or deleted",
|
|
254
|
+
});
|
|
255
|
+
if (!effectiveEnforce) return { snapshot: null };
|
|
256
|
+
throw notFoundError();
|
|
257
|
+
}
|
|
258
|
+
return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const snapshot = await this._getAccessSnapshot(sessionId, owner);
|
|
262
|
+
return this._decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_decideSessionAccess(method, accessClass, snapshot, sessionId, authContext, { isAdmin, effectiveEnforce }) {
|
|
266
|
+
const decision = evaluateSessionAccess(accessClass, snapshot, {
|
|
267
|
+
isAdmin,
|
|
268
|
+
systemReadable: this.authz.systemVisibility === "read",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (decision.allowed) {
|
|
272
|
+
if (decision.breakGlass && BREAK_GLASS_AUDITED[accessClass !== "session:read" ? "any" : method]) {
|
|
273
|
+
const actor = this._auditActor(authContext);
|
|
274
|
+
const actorKey = `${actor.provider}/${actor.subject}`;
|
|
275
|
+
if (this._shouldRecordBreakGlass(actorKey, String(sessionId))) {
|
|
276
|
+
this._recordAudit({
|
|
277
|
+
actor,
|
|
278
|
+
action: method,
|
|
279
|
+
sessionId: String(sessionId),
|
|
280
|
+
decision: "break_glass",
|
|
281
|
+
reason: "Admin access to a private session owned by another user",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { snapshot };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this._recordAudit({
|
|
289
|
+
actor: this._auditActor(authContext),
|
|
290
|
+
action: method,
|
|
291
|
+
sessionId: sessionId ? String(sessionId) : null,
|
|
292
|
+
decision: effectiveEnforce ? "deny" : "would_deny",
|
|
293
|
+
reason: decision.notFound ? "not visible" : decision.reason,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (!effectiveEnforce) return { snapshot };
|
|
297
|
+
throw decision.notFound ? notFoundError() : forbiddenError(decision.reason);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Facts write containment: non-admin callers may write/delete shared
|
|
302
|
+
* facts (the deployment's collaboration memory) and facts of sessions
|
|
303
|
+
* they can WRITE; anything else — in particular pattern deletes over
|
|
304
|
+
* other sessions' private facts — is denied.
|
|
305
|
+
*/
|
|
306
|
+
async _authorizeFactsWrite(method, safeParams, authContext, { owner, isAdmin }) {
|
|
307
|
+
if (isAdmin) return;
|
|
308
|
+
const inputs = Array.isArray(safeParams.input) ? safeParams.input : [safeParams.input];
|
|
309
|
+
for (const input of inputs) {
|
|
310
|
+
const sessionId = typeof input?.sessionId === "string" && input.sessionId.trim() ? input.sessionId.trim() : null;
|
|
311
|
+
const scopeKey = typeof input?.scopeKey === "string" ? input.scopeKey : "";
|
|
312
|
+
const sessionScopeFromKey = scopeKey.startsWith("session:") ? scopeKey.split(":")[1] || null : null;
|
|
313
|
+
const targetSession = sessionId || sessionScopeFromKey;
|
|
314
|
+
const isSharedScope = !targetSession && (input?.shared === true || scopeKey.startsWith("shared:") || input?.scope === "shared" || (!scopeKey && !sessionId));
|
|
315
|
+
if (isSharedScope) continue;
|
|
316
|
+
await this._gateSession(method, "session:write", targetSession, authContext, { owner, isAdmin });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async _authorizeGroupManage(method, safeParams, authContext, { owner, isAdmin }) {
|
|
321
|
+
if (isAdmin) return;
|
|
322
|
+
const groupId = safeParams.groupId ? String(safeParams.groupId) : null;
|
|
323
|
+
if (groupId) {
|
|
324
|
+
const groups = await this.transport.listSessionGroups().catch(() => []);
|
|
325
|
+
const group = (groups || []).find((g) => g.groupId === groupId);
|
|
326
|
+
if (group) {
|
|
327
|
+
const groupOwner = normalizeOwnerPrincipal(group.owner);
|
|
328
|
+
const allowed = !groupOwner || (owner && groupOwner.provider === owner.provider && groupOwner.subject === owner.subject);
|
|
329
|
+
if (!allowed) {
|
|
330
|
+
this._recordAudit({
|
|
331
|
+
actor: this._auditActor(authContext),
|
|
332
|
+
action: method,
|
|
333
|
+
target: `group:${groupId}`,
|
|
334
|
+
decision: this.authz.enforce ? "deny" : "would_deny",
|
|
335
|
+
reason: "group owned by another user",
|
|
336
|
+
});
|
|
337
|
+
if (this.authz.enforce) {
|
|
338
|
+
throw forbiddenError("Only the group owner or an admin can manage this group.");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Assign/move (including ungroup, groupId=null) also mutates the
|
|
344
|
+
// sessions themselves — gate each as session:manage so a user can't
|
|
345
|
+
// pull another user's session out of (or into) a group
|
|
346
|
+
// (adversarial review MEDIUM-2).
|
|
347
|
+
const sessionIds = Array.isArray(safeParams.sessionIds) ? safeParams.sessionIds : [];
|
|
348
|
+
for (const sessionId of sessionIds) {
|
|
349
|
+
await this._gateSession(method, "session:manage", sessionId, authContext, { owner, isAdmin });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Placement viewer for the CMS placement procs. canRead inside the procs
|
|
355
|
+
* is permissive when ownership enforcement is off (admin OR NOT enforce);
|
|
356
|
+
* the target-group ownership check is always enforced regardless.
|
|
357
|
+
*/
|
|
358
|
+
_placementViewer(authContext, isAdmin) {
|
|
359
|
+
return { ...placementPrincipal(authContext), isAdmin: isAdmin || !this.authz.enforce };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Viewer-private placement: upsert (or clear, when groupId is null) the
|
|
364
|
+
* caller's own group placement for each session tree root. Requires read
|
|
365
|
+
* access per session; the target group must be owned by the caller —
|
|
366
|
+
* cross-user placement is structurally impossible.
|
|
367
|
+
*/
|
|
368
|
+
async _placeSessionsInGroup(method, safeParams, authContext, { isAdmin }) {
|
|
369
|
+
const groupId = safeParams.groupId == null ? null : String(safeParams.groupId).trim() || null;
|
|
370
|
+
const sessionIds = Array.isArray(safeParams.sessionIds) ? safeParams.sessionIds : [];
|
|
371
|
+
try {
|
|
372
|
+
return await this.transport.mgmt.placeSessionsInGroup(
|
|
373
|
+
this._placementViewer(authContext, isAdmin),
|
|
374
|
+
sessionIds,
|
|
375
|
+
groupId,
|
|
376
|
+
);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (/was not found or is not owned by the caller/i.test(String(error?.message || ""))) {
|
|
379
|
+
this._recordAudit({
|
|
380
|
+
actor: this._auditActor(authContext),
|
|
381
|
+
action: method,
|
|
382
|
+
target: `group:${groupId}`,
|
|
383
|
+
decision: "deny",
|
|
384
|
+
reason: "group not found or not owned by the caller",
|
|
385
|
+
});
|
|
386
|
+
throw forbiddenError("Session group not found or not owned by you.");
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A creator-supplied groupId is an initial placement: it must be one of the caller's groups. */
|
|
393
|
+
async _assertPlacementGroupOwned(groupId, authContext, { isAdmin }) {
|
|
394
|
+
const normalized = groupId == null ? null : String(groupId).trim() || null;
|
|
395
|
+
if (!normalized) return;
|
|
396
|
+
const groups = await this.transport.mgmt.listSessionGroups(this._placementViewer(authContext, isAdmin));
|
|
397
|
+
if (!(groups || []).some((group) => group.groupId === normalized)) {
|
|
398
|
+
throw forbiddenError("Session group not found or not owned by you.");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Guarantee a created session lands in the requested group under the
|
|
404
|
+
* placement principal, not the session owner. CMS places in-transaction
|
|
405
|
+
* only when an owner principal reaches it, so no-auth deployments (owner
|
|
406
|
+
* null, placement viewer anonymous) would otherwise drop the group
|
|
407
|
+
* silently. The upsert is idempotent, so the authenticated path (already
|
|
408
|
+
* placed) is skipped via the viewerGroupId check; ownership was verified
|
|
409
|
+
* before create, so a placement failure is unexpected and best-effort.
|
|
410
|
+
*/
|
|
411
|
+
async _ensureCreatedPlacement(view, groupId, authContext, isAdmin) {
|
|
412
|
+
const normalized = groupId == null ? null : String(groupId).trim() || null;
|
|
413
|
+
if (!normalized || !view?.sessionId || view.viewerGroupId === normalized) return view;
|
|
414
|
+
try {
|
|
415
|
+
await this.transport.mgmt.placeSessionsInGroup(
|
|
416
|
+
this._placementViewer(authContext, isAdmin),
|
|
417
|
+
[view.sessionId],
|
|
418
|
+
normalized,
|
|
419
|
+
);
|
|
420
|
+
return { ...view, viewerGroupId: normalized };
|
|
421
|
+
} catch {
|
|
422
|
+
return view;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Viewer descriptor for viewer-scoped listing, or null for unfiltered.
|
|
428
|
+
* A non-admin without a resolvable identity in enforce mode gets a viewer
|
|
429
|
+
* that matches no owner and no targeted share, so they see only
|
|
430
|
+
* deployment-shared trees (shared_read/shared_write are visible to every
|
|
431
|
+
* admitted user by design) — never the unfiltered fleet or another user's
|
|
432
|
+
* private sessions (adversarial review LOW-1 / NEW-5).
|
|
433
|
+
*/
|
|
434
|
+
_listViewer(owner, isAdmin) {
|
|
435
|
+
if (isAdmin || !this.authz.enforce) return null;
|
|
436
|
+
if (!owner) return { provider: "nomatch", subject: "nomatch", systemVisible: false };
|
|
437
|
+
return {
|
|
438
|
+
provider: owner.provider,
|
|
439
|
+
subject: owner.subject,
|
|
440
|
+
systemVisible: this.authz.systemVisibility === "read",
|
|
441
|
+
};
|
|
91
442
|
}
|
|
92
443
|
|
|
93
444
|
async start() {
|
|
@@ -116,26 +467,14 @@ export class PortalRuntime {
|
|
|
116
467
|
}
|
|
117
468
|
|
|
118
469
|
async resolveSessionGroupOwner(input = {}, authOwner = null) {
|
|
470
|
+
// Groups belong to the authenticated creator. Ownership is never
|
|
471
|
+
// inferred from selected sessions, and never null: no-auth
|
|
472
|
+
// deployments use the same anonymous principal as placement so
|
|
473
|
+
// every group can receive placements.
|
|
474
|
+
if (authOwner) return authOwner;
|
|
119
475
|
const inputOwner = normalizeOwnerPrincipal(input?.owner);
|
|
120
476
|
if (inputOwner) return inputOwner;
|
|
121
|
-
|
|
122
|
-
const sessionIds = Array.isArray(input?.sessionIds)
|
|
123
|
-
? Array.from(new Set(input.sessionIds.map((id) => String(id || "").trim()).filter(Boolean)))
|
|
124
|
-
: [];
|
|
125
|
-
if (sessionIds.length > 0 && typeof this.transport.getSession === "function") {
|
|
126
|
-
const owners = [];
|
|
127
|
-
for (const sessionId of sessionIds) {
|
|
128
|
-
const session = await this.transport.getSession(sessionId).catch(() => null);
|
|
129
|
-
if (!session || session.isSystem || session.isGroup || session.parentSessionId) continue;
|
|
130
|
-
owners.push(normalizeOwnerPrincipal(session.owner));
|
|
131
|
-
}
|
|
132
|
-
const ownerKeys = new Set(owners.map((owner) => ownerKey(owner) || ""));
|
|
133
|
-
if (owners.length > 0 && ownerKeys.size === 1) {
|
|
134
|
-
return owners[0] ?? null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return authOwner;
|
|
477
|
+
return { provider: "anonymous", subject: "anonymous" };
|
|
139
478
|
}
|
|
140
479
|
|
|
141
480
|
async getBootstrap() {
|
|
@@ -160,6 +499,14 @@ export class PortalRuntime {
|
|
|
160
499
|
sessionCreationPolicy: typeof this.transport.getSessionCreationPolicy === "function"
|
|
161
500
|
? this.transport.getSessionCreationPolicy()
|
|
162
501
|
: null,
|
|
502
|
+
// Ownership/visibility posture (security model) so clients (portal,
|
|
503
|
+
// MCP, TUI) can explain why a session isn't listed or a send was
|
|
504
|
+
// refused, and default the share UI correctly.
|
|
505
|
+
authz: {
|
|
506
|
+
ownershipEnforced: this.authz.enforce,
|
|
507
|
+
defaultVisibility: this.authz.defaultVisibility,
|
|
508
|
+
systemVisibility: this.authz.systemVisibility,
|
|
509
|
+
},
|
|
163
510
|
};
|
|
164
511
|
}
|
|
165
512
|
|
|
@@ -172,11 +519,19 @@ export class PortalRuntime {
|
|
|
172
519
|
// visibility so a plain caller cannot read another session's private facts.
|
|
173
520
|
const role = authContext?.authorization?.role;
|
|
174
521
|
const isAdmin = role === "admin" || role === "anonymous";
|
|
522
|
+
// Ownership/visibility gate — the single enforcement point for both
|
|
523
|
+
// the generated /api/v1 routes and the legacy /api/rpc dispatcher.
|
|
524
|
+
const gate = await this._authorizeCall(method, safeParams, authContext, { owner, isAdmin });
|
|
525
|
+
const listViewer = this._listViewer(owner, isAdmin);
|
|
175
526
|
switch (method) {
|
|
176
527
|
case "listSessions":
|
|
177
|
-
return
|
|
528
|
+
return listViewer
|
|
529
|
+
? this.transport.mgmt.listSessionsVisible(listViewer, placementPrincipal(authContext))
|
|
530
|
+
: this.transport.mgmt.listSessions(placementPrincipal(authContext));
|
|
178
531
|
case "listSessionGroups":
|
|
179
|
-
|
|
532
|
+
// Viewer-scoped: everyone (admins included) sees only their
|
|
533
|
+
// own groups — a group is a user's private organization.
|
|
534
|
+
return this.transport.mgmt.listSessionGroups(this._placementViewer(authContext, isAdmin));
|
|
180
535
|
case "createSessionGroup":
|
|
181
536
|
return this.transport.createSessionGroup({
|
|
182
537
|
...(safeParams.input || {}),
|
|
@@ -184,18 +539,22 @@ export class PortalRuntime {
|
|
|
184
539
|
});
|
|
185
540
|
case "updateSessionGroup":
|
|
186
541
|
return this.transport.updateSessionGroup(safeParams.groupId, safeParams.patch || {});
|
|
542
|
+
case "placeSessionsInGroup":
|
|
187
543
|
case "assignSessionsToGroup":
|
|
188
|
-
return this.transport.assignSessionsToGroup(safeParams.groupId, safeParams.sessionIds || []);
|
|
189
544
|
case "moveSessionsToGroup":
|
|
190
|
-
return this.
|
|
545
|
+
return this._placeSessionsInGroup(method, safeParams, authContext, { isAdmin });
|
|
191
546
|
case "getChildOutcome":
|
|
192
547
|
return this.transport.getChildOutcome(safeParams.childSessionId);
|
|
193
548
|
case "listChildOutcomes":
|
|
194
549
|
return this.transport.listChildOutcomes(safeParams.parentSessionId);
|
|
195
550
|
case "listSessionsPage":
|
|
196
|
-
return this.transport.listSessionsPage(
|
|
551
|
+
return this.transport.mgmt.listSessionsPage({
|
|
552
|
+
...normalizeSessionPageOptions(safeParams),
|
|
553
|
+
...(listViewer ? { viewer: listViewer } : {}),
|
|
554
|
+
placement: placementPrincipal(authContext),
|
|
555
|
+
});
|
|
197
556
|
case "getSession":
|
|
198
|
-
return this.transport.getSession(safeParams.sessionId);
|
|
557
|
+
return this.transport.mgmt.getSession(safeParams.sessionId, placementPrincipal(authContext));
|
|
199
558
|
case "getOrchestrationStats":
|
|
200
559
|
return this.transport.getOrchestrationStats(safeParams.sessionId);
|
|
201
560
|
case "getSessionMetricSummary":
|
|
@@ -357,16 +716,21 @@ export class PortalRuntime {
|
|
|
357
716
|
return this.transport.pruneDeletedSummaries(new Date(safeParams.olderThan));
|
|
358
717
|
case "getExecutionHistory":
|
|
359
718
|
return this.transport.getExecutionHistory(safeParams.sessionId, safeParams.executionId);
|
|
360
|
-
case "createSession":
|
|
361
|
-
|
|
719
|
+
case "createSession": {
|
|
720
|
+
await this._assertPlacementGroupOwned(safeParams.groupId, authContext, { isAdmin });
|
|
721
|
+
const created = await this.transport.createSession({
|
|
362
722
|
model: safeParams.model,
|
|
363
723
|
reasoningEffort: safeParams.reasoningEffort,
|
|
364
724
|
contextTier: safeParams.contextTier,
|
|
365
725
|
groupId: safeParams.groupId,
|
|
366
726
|
owner,
|
|
727
|
+
visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
|
|
367
728
|
});
|
|
368
|
-
|
|
369
|
-
|
|
729
|
+
return this._ensureCreatedPlacement(created, safeParams.groupId, authContext, isAdmin);
|
|
730
|
+
}
|
|
731
|
+
case "createSessionForAgent": {
|
|
732
|
+
await this._assertPlacementGroupOwned(safeParams.groupId, authContext, { isAdmin });
|
|
733
|
+
const created = await this.transport.createSessionForAgent(safeParams.agentName, {
|
|
370
734
|
model: safeParams.model,
|
|
371
735
|
reasoningEffort: safeParams.reasoningEffort,
|
|
372
736
|
contextTier: safeParams.contextTier,
|
|
@@ -376,17 +740,113 @@ export class PortalRuntime {
|
|
|
376
740
|
initialPrompt: safeParams.initialPrompt,
|
|
377
741
|
groupId: safeParams.groupId,
|
|
378
742
|
owner,
|
|
743
|
+
visibility: normalizeVisibility(safeParams.visibility, this.authz.defaultVisibility),
|
|
379
744
|
});
|
|
745
|
+
return this._ensureCreatedPlacement(created, safeParams.groupId, authContext, isAdmin);
|
|
746
|
+
}
|
|
380
747
|
case "listCreatableAgents":
|
|
381
748
|
return this.transport.listCreatableAgents();
|
|
382
749
|
case "getSessionCreationPolicy":
|
|
383
750
|
return this.transport.getSessionCreationPolicy();
|
|
384
751
|
case "sendMessage":
|
|
385
|
-
return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt,
|
|
752
|
+
return this.transport.sendMessage(safeParams.sessionId, safeParams.prompt, {
|
|
753
|
+
...(safeParams.options && typeof safeParams.options === "object" ? safeParams.options : {}),
|
|
754
|
+
// Server-stamped; a client-supplied options.sender is overwritten.
|
|
755
|
+
sender: this._buildSender(authContext, gate.snapshot, { isAdmin, origin: safeParams.options?.origin }),
|
|
756
|
+
});
|
|
386
757
|
case "sendAnswer":
|
|
387
|
-
return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer
|
|
758
|
+
return this.transport.sendAnswer(safeParams.sessionId, safeParams.answer, {
|
|
759
|
+
sender: this._buildSender(authContext, gate.snapshot, { isAdmin }),
|
|
760
|
+
});
|
|
388
761
|
case "sendSessionEvent":
|
|
389
762
|
return this.transport.sendSessionEvent(safeParams.sessionId, safeParams.eventName, safeParams.data);
|
|
763
|
+
|
|
764
|
+
// ── Session sharing (security model) ────────────────────────
|
|
765
|
+
case "getSessionAccess": {
|
|
766
|
+
const snapshot = gate.snapshot ?? await this._getAccessSnapshot(safeParams.sessionId, owner);
|
|
767
|
+
if (!snapshot) {
|
|
768
|
+
throw notFoundError();
|
|
769
|
+
}
|
|
770
|
+
const relation = snapshot.viewerIsOwner ? "owner" : (isAdmin ? "admin" : (snapshot.viewerShareAccess ? "collaborator" : "none"));
|
|
771
|
+
const canWrite = isAdmin || snapshot.viewerIsOwner || snapshot.visibility === "shared_write" || snapshot.viewerShareAccess === "write";
|
|
772
|
+
const canManage = isAdmin || snapshot.viewerIsOwner;
|
|
773
|
+
// The caller's private placement of this tree (placements
|
|
774
|
+
// live on the root), never another viewer's.
|
|
775
|
+
const rootView = snapshot.rootSessionId
|
|
776
|
+
? await this.transport.mgmt.getSession(snapshot.rootSessionId, placementPrincipal(authContext)).catch(() => null)
|
|
777
|
+
: null;
|
|
778
|
+
return {
|
|
779
|
+
sessionId: safeParams.sessionId,
|
|
780
|
+
rootSessionId: snapshot.rootSessionId,
|
|
781
|
+
isSystem: snapshot.isSystem,
|
|
782
|
+
visibility: snapshot.visibility,
|
|
783
|
+
owner: snapshot.owner,
|
|
784
|
+
relation,
|
|
785
|
+
canWrite: snapshot.isSystem ? isAdmin : canWrite,
|
|
786
|
+
canManage: snapshot.isSystem ? isAdmin : canManage,
|
|
787
|
+
viewerGroupId: rootView?.viewerGroupId ?? null,
|
|
788
|
+
enforced: this.authz.enforce,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
case "setSessionVisibility": {
|
|
792
|
+
const visibility = normalizeVisibility(safeParams.visibility, null);
|
|
793
|
+
if (!visibility) {
|
|
794
|
+
throw Object.assign(new Error("visibility must be private | shared_read | shared_write"), { code: "INVALID_REQUEST" });
|
|
795
|
+
}
|
|
796
|
+
await this.transport.setSessionVisibility(safeParams.sessionId, visibility);
|
|
797
|
+
this._recordAudit({
|
|
798
|
+
actor: this._auditActor(authContext),
|
|
799
|
+
action: "setSessionVisibility",
|
|
800
|
+
sessionId: String(safeParams.sessionId),
|
|
801
|
+
decision: "share_change",
|
|
802
|
+
reason: `visibility=${visibility}`,
|
|
803
|
+
});
|
|
804
|
+
return { sessionId: safeParams.sessionId, visibility };
|
|
805
|
+
}
|
|
806
|
+
case "grantSessionShare": {
|
|
807
|
+
const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
|
|
808
|
+
const access = safeParams.access === "write" ? "write" : safeParams.access === "read" ? "read" : null;
|
|
809
|
+
if (!grantee.provider || !grantee.subject || !access) {
|
|
810
|
+
throw Object.assign(new Error("grantSessionShare requires user { provider, subject } and access read|write"), { code: "INVALID_REQUEST" });
|
|
811
|
+
}
|
|
812
|
+
await this.transport.grantSessionShare(safeParams.sessionId, grantee, access, owner);
|
|
813
|
+
this._recordAudit({
|
|
814
|
+
actor: this._auditActor(authContext),
|
|
815
|
+
action: "grantSessionShare",
|
|
816
|
+
sessionId: String(safeParams.sessionId),
|
|
817
|
+
target: `${grantee.provider}/${grantee.subject}`,
|
|
818
|
+
decision: "share_change",
|
|
819
|
+
reason: `access=${access}`,
|
|
820
|
+
});
|
|
821
|
+
return { sessionId: safeParams.sessionId, granted: { ...grantee, access } };
|
|
822
|
+
}
|
|
823
|
+
case "revokeSessionShare": {
|
|
824
|
+
const grantee = safeParams.user && typeof safeParams.user === "object" ? safeParams.user : {};
|
|
825
|
+
if (!grantee.provider || !grantee.subject) {
|
|
826
|
+
throw Object.assign(new Error("revokeSessionShare requires user { provider, subject }"), { code: "INVALID_REQUEST" });
|
|
827
|
+
}
|
|
828
|
+
await this.transport.revokeSessionShare(safeParams.sessionId, grantee);
|
|
829
|
+
this._recordAudit({
|
|
830
|
+
actor: this._auditActor(authContext),
|
|
831
|
+
action: "revokeSessionShare",
|
|
832
|
+
sessionId: String(safeParams.sessionId),
|
|
833
|
+
target: `${grantee.provider}/${grantee.subject}`,
|
|
834
|
+
decision: "share_change",
|
|
835
|
+
reason: "revoked",
|
|
836
|
+
});
|
|
837
|
+
return { sessionId: safeParams.sessionId, revoked: grantee };
|
|
838
|
+
}
|
|
839
|
+
case "listSessionShares":
|
|
840
|
+
return this.transport.listSessionShares(safeParams.sessionId);
|
|
841
|
+
case "listKnownUsers":
|
|
842
|
+
return typeof this.transport.listKnownUsers === "function"
|
|
843
|
+
? this.transport.listKnownUsers({ limit: safeParams.limit })
|
|
844
|
+
: [];
|
|
845
|
+
case "listAuthzAudit":
|
|
846
|
+
return this.transport.listAuthzAudit({
|
|
847
|
+
limit: safeParams.limit,
|
|
848
|
+
sessionId: safeParams.sessionId ?? null,
|
|
849
|
+
});
|
|
390
850
|
case "getSessionStatus":
|
|
391
851
|
return this.transport.getSessionStatus(safeParams.sessionId);
|
|
392
852
|
case "waitForStatusChange": {
|
|
@@ -481,19 +941,42 @@ export class PortalRuntime {
|
|
|
481
941
|
}
|
|
482
942
|
}
|
|
483
943
|
|
|
484
|
-
|
|
944
|
+
/**
|
|
945
|
+
* Server-stamped message sender: identity from the validated auth
|
|
946
|
+
* context, relation from the access snapshot. Never trusts
|
|
947
|
+
* client-supplied identity fields; `origin` is client-declared display
|
|
948
|
+
* metadata only.
|
|
949
|
+
*/
|
|
950
|
+
_buildSender(authContext, snapshot, { isAdmin = false, origin } = {}) {
|
|
951
|
+
const principal = normalizeSessionOwner(authContext);
|
|
952
|
+
if (!principal) return undefined;
|
|
953
|
+
const allowedOrigins = new Set(["portal", "tui", "mcp", "api"]);
|
|
954
|
+
return {
|
|
955
|
+
kind: "user",
|
|
956
|
+
provider: principal.provider,
|
|
957
|
+
subject: principal.subject,
|
|
958
|
+
display: principal.displayName || principal.email || principal.subject,
|
|
959
|
+
relation: relationFor(snapshot, { isAdmin }),
|
|
960
|
+
origin: allowedOrigins.has(origin) ? origin : "api",
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async downloadArtifact(sessionId, filename, authContext = null) {
|
|
485
965
|
await this.start();
|
|
966
|
+
await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
|
|
486
967
|
return this.transport.downloadArtifact(sessionId, filename);
|
|
487
968
|
}
|
|
488
969
|
|
|
489
|
-
async getArtifactMetadata(sessionId, filename) {
|
|
970
|
+
async getArtifactMetadata(sessionId, filename, authContext = null) {
|
|
490
971
|
await this.start();
|
|
972
|
+
await this._gateBespokeRead("getArtifactMetadata", sessionId, authContext);
|
|
491
973
|
if (typeof this.transport.getArtifactMetadata !== "function") return null;
|
|
492
974
|
return this.transport.getArtifactMetadata(sessionId, filename);
|
|
493
975
|
}
|
|
494
976
|
|
|
495
|
-
async downloadArtifactBinary(sessionId, filename) {
|
|
977
|
+
async downloadArtifactBinary(sessionId, filename, authContext = null) {
|
|
496
978
|
await this.start();
|
|
979
|
+
await this._gateBespokeRead("downloadArtifact", sessionId, authContext);
|
|
497
980
|
if (typeof this.transport.downloadArtifactBinary === "function") {
|
|
498
981
|
return this.transport.downloadArtifactBinary(sessionId, filename);
|
|
499
982
|
}
|
|
@@ -509,6 +992,39 @@ export class PortalRuntime {
|
|
|
509
992
|
};
|
|
510
993
|
}
|
|
511
994
|
|
|
995
|
+
/** session:read gate for the bespoke (non-dispatched) artifact routes. */
|
|
996
|
+
async _gateBespokeRead(action, sessionId, authContext) {
|
|
997
|
+
const role = authContext?.authorization?.role;
|
|
998
|
+
const isAdmin = role === "admin" || role === "anonymous";
|
|
999
|
+
const owner = normalizeSessionOwner(authContext);
|
|
1000
|
+
await this._gateSession(action, "session:read", sessionId, authContext, { owner, isAdmin });
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* WebSocket subscription gate (api/ws.js). Throws 403/404 when the
|
|
1005
|
+
* caller cannot read the session; audited like every other decision.
|
|
1006
|
+
*/
|
|
1007
|
+
async authorizeSessionSubscribe(sessionId, authContext) {
|
|
1008
|
+
await this.start();
|
|
1009
|
+
await this._gateBespokeRead("subscribeSession", sessionId, authContext);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Log tail is fleet-wide observability: admin (or dark-launch). */
|
|
1013
|
+
async authorizeLogSubscribe(authContext) {
|
|
1014
|
+
const role = authContext?.authorization?.role;
|
|
1015
|
+
const isAdmin = role === "admin" || role === "anonymous";
|
|
1016
|
+
if (isAdmin) return;
|
|
1017
|
+
this._recordAudit({
|
|
1018
|
+
actor: this._auditActor(authContext),
|
|
1019
|
+
action: "subscribeLogs",
|
|
1020
|
+
decision: this.authz.enforce ? "deny" : "would_deny",
|
|
1021
|
+
reason: "log tail requires the admin role",
|
|
1022
|
+
});
|
|
1023
|
+
if (this.authz.enforce) {
|
|
1024
|
+
throw forbiddenError("The live log tail requires the admin role.");
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
512
1028
|
subscribeSession(sessionId, handler) {
|
|
513
1029
|
return this.transport.subscribeSession(sessionId, handler);
|
|
514
1030
|
}
|