pilotswarm 0.5.13 → 0.5.14
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 +12 -0
- 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/tools/capabilities.d.ts +3 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +8 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +114 -0
- 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 +53 -8
- 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 +275 -6
- package/ui/core/src/history.js +19 -1
- package/ui/core/src/reducer.js +4 -0
- package/ui/core/src/selectors.js +139 -14
- package/ui/core/src/themes/helpers.js +4 -0
- package/ui/react/src/components.js +95 -6
- package/ui/react/src/web-app.js +555 -117
- 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-BnxC8cNG.js +24 -0
- package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
- package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
- package/web/dist/assets/react-l0sNRNKZ.js +1 -0
- package/web/dist/index.html +3 -4
- package/web/runtime.js +453 -9
- package/web/server.js +2 -2
- package/web/dist/assets/index-bQ2QInMX.js +0 -24
- package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
- package/web/dist/assets/react-C9iQPS2h.js +0 -1
package/web/api/router.js
CHANGED
|
@@ -137,12 +137,12 @@ export function createApiRouter({ runtime, requireAuth }) {
|
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
139
|
try {
|
|
140
|
-
const artifact = await runtime.downloadArtifactBinary(req.params.sessionId, req.params.filename);
|
|
140
|
+
const artifact = await runtime.downloadArtifactBinary(req.params.sessionId, req.params.filename, req.auth);
|
|
141
141
|
res.setHeader("content-type", String(artifact?.contentType || "application/octet-stream"));
|
|
142
142
|
res.setHeader("content-disposition", `attachment; filename="${path.basename(req.params.filename)}"`);
|
|
143
143
|
res.send(artifact.body);
|
|
144
144
|
} catch (error) {
|
|
145
|
-
sendError(res, error, 404);
|
|
145
|
+
sendError(res, error, error?.code === "FORBIDDEN" ? 403 : 404);
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
@@ -151,10 +151,11 @@ export function createApiRouter({ runtime, requireAuth }) {
|
|
|
151
151
|
const expressPath = op.path.replace(/:([\w]+)/g, ":$1");
|
|
152
152
|
router[op.method.toLowerCase()](expressPath, async (req, res) => {
|
|
153
153
|
try {
|
|
154
|
-
// Tier-2 operational ops require the admin role
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
154
|
+
// Tier-2 operational ops require the admin role — a hard gate
|
|
155
|
+
// regardless of the ownership dark-launch flag. Finer-grained
|
|
156
|
+
// ownership/visibility classes (op.access) are enforced inside
|
|
157
|
+
// runtime.call(), the shared dispatch chokepoint.
|
|
158
|
+
if ((op.admin || op.access === "fleet:admin") && !isAdminAuth(req.auth)) {
|
|
158
159
|
sendError(res, Object.assign(new Error("This operation requires the admin role."), { code: "FORBIDDEN" }), 403);
|
|
159
160
|
return;
|
|
160
161
|
}
|
package/web/api/ws.js
CHANGED
|
@@ -52,6 +52,11 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
|
|
|
52
52
|
if (!sessionId || sessionSubscriptions.has(sessionId)) return;
|
|
53
53
|
try {
|
|
54
54
|
await runtime.start();
|
|
55
|
+
// Ownership/visibility gate: live events are a content
|
|
56
|
+
// read, same predicate as the REST catch-up path.
|
|
57
|
+
if (typeof runtime.authorizeSessionSubscribe === "function") {
|
|
58
|
+
await runtime.authorizeSessionSubscribe(sessionId, auth);
|
|
59
|
+
}
|
|
55
60
|
const unsubscribe = runtime.subscribeSession(sessionId, (event) => {
|
|
56
61
|
send({ type: "sessionEvent", sessionId, event });
|
|
57
62
|
});
|
|
@@ -77,6 +82,10 @@ export function createConnectionHandler(runtime, { allowThemeMessages = false }
|
|
|
77
82
|
if (logUnsubscribe) return;
|
|
78
83
|
try {
|
|
79
84
|
await runtime.start();
|
|
85
|
+
// Log tail is fleet-wide observability: admin-gated.
|
|
86
|
+
if (typeof runtime.authorizeLogSubscribe === "function") {
|
|
87
|
+
await runtime.authorizeLogSubscribe(auth);
|
|
88
|
+
}
|
|
80
89
|
logUnsubscribe = runtime.startLogTail((entry) => {
|
|
81
90
|
send({ type: "logEntry", entry });
|
|
82
91
|
});
|
package/web/auth/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { createNoAuthProvider } from "./providers/none.js";
|
|
2
2
|
import { createEntraAuthProvider } from "./providers/entra.js";
|
|
3
|
+
import { createDevAuthProvider } from "./providers/dev.js";
|
|
3
4
|
import { authorizePrincipal } from "./authz/engine.js";
|
|
4
5
|
import { loadAuthorizationPolicy, resolveAuthProviderId, resolvePluginAuthConfigFromPluginDirs } from "./config.js";
|
|
5
6
|
|
|
6
7
|
const PROVIDERS = {
|
|
7
8
|
none: createNoAuthProvider,
|
|
8
9
|
entra: createEntraAuthProvider,
|
|
10
|
+
// Test personas, no real authentication. Never inferred — only an explicit
|
|
11
|
+
// PORTAL_AUTH_PROVIDER=dev selects it, and its factory throws without
|
|
12
|
+
// PORTAL_AUTH_DEV_ALLOW=true (see providers/dev.js).
|
|
13
|
+
dev: createDevAuthProvider,
|
|
9
14
|
};
|
|
10
15
|
|
|
11
16
|
const NO_AUTH_UNKNOWN_PRINCIPAL = Object.freeze({
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Development auth provider: authenticates as one of a small roster of
|
|
2
|
+
// predefined personas with zero IdP involvement. The "token" is the literal
|
|
3
|
+
// string `dev:<persona>` so it rides every existing credential path (Bearer
|
|
4
|
+
// header, WebSocket subprotocol, PILOTSWARM_API_TOKEN) unchanged.
|
|
5
|
+
//
|
|
6
|
+
// This provider exists to exercise the multi-user security model (ownership,
|
|
7
|
+
// visibility, sharing) on a laptop — see
|
|
8
|
+
// docs/proposals/dev-auth-provider-and-multiuser-test-plan.md. Any holder of
|
|
9
|
+
// the string IS that persona, so the guards below are deliberately strict:
|
|
10
|
+
// never inferred, explicit second opt-in env, and mutual exclusion with Entra.
|
|
11
|
+
|
|
12
|
+
const DEV_BANNER = "DEV AUTH — not for production";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ROSTER = [
|
|
15
|
+
{ id: "ada", displayName: "Ada Admin", role: "admin" },
|
|
16
|
+
{ id: "alice", displayName: "Alice Anderson", role: "user" },
|
|
17
|
+
{ id: "bob", displayName: "Bob Baker", role: "user" },
|
|
18
|
+
{ id: "carol", displayName: "Carol Chen", role: "user" },
|
|
19
|
+
{ id: "dave", displayName: "Dave Diaz", role: "user" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function isTruthyEnv(value) {
|
|
23
|
+
return ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titleCase(id) {
|
|
27
|
+
return id.charAt(0).toUpperCase() + id.slice(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseDevRoster(raw) {
|
|
31
|
+
const trimmed = String(raw || "").trim();
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
return DEFAULT_ROSTER.map((persona) => ({ ...persona, email: `${persona.id}@dev.local` }));
|
|
34
|
+
}
|
|
35
|
+
const personas = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const entry of trimmed.split(",")) {
|
|
38
|
+
const cleaned = entry.trim();
|
|
39
|
+
if (!cleaned) continue;
|
|
40
|
+
const [rawId, rawRole] = cleaned.split(":").map((part) => String(part || "").trim());
|
|
41
|
+
const id = rawId.toLowerCase();
|
|
42
|
+
const role = String(rawRole || "user").toLowerCase();
|
|
43
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(id)) {
|
|
44
|
+
throw new Error(`[portal-auth:dev] Invalid persona id "${rawId}" in PORTAL_AUTH_DEV_USERS (expected [a-z][a-z0-9_-]*)`);
|
|
45
|
+
}
|
|
46
|
+
if (role !== "admin" && role !== "user") {
|
|
47
|
+
throw new Error(`[portal-auth:dev] Invalid role "${rawRole}" for persona "${id}" in PORTAL_AUTH_DEV_USERS (expected admin|user)`);
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(id)) {
|
|
50
|
+
throw new Error(`[portal-auth:dev] Duplicate persona id "${id}" in PORTAL_AUTH_DEV_USERS`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(id);
|
|
53
|
+
personas.push({
|
|
54
|
+
id,
|
|
55
|
+
displayName: `${titleCase(id)} (dev)`,
|
|
56
|
+
email: `${id}@dev.local`,
|
|
57
|
+
role,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (personas.length === 0) {
|
|
61
|
+
throw new Error("[portal-auth:dev] PORTAL_AUTH_DEV_USERS is set but contains no personas");
|
|
62
|
+
}
|
|
63
|
+
return personas;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createDevAuthProvider({ env = process.env } = {}) {
|
|
67
|
+
const configuredEntraKeys = Object.entries(env)
|
|
68
|
+
.filter(([key, value]) => key.startsWith("PORTAL_AUTH_ENTRA_") && String(value || "").trim())
|
|
69
|
+
.map(([key]) => key);
|
|
70
|
+
if (configuredEntraKeys.length > 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`[portal-auth:dev] Refusing to start: PORTAL_AUTH_ENTRA_* is configured (${configuredEntraKeys.join(", ")}). `
|
|
73
|
+
+ "The dev provider performs no real authentication and cannot coexist with a real identity provider.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!isTruthyEnv(env.PORTAL_AUTH_DEV_ALLOW)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"[portal-auth:dev] Refusing to start: the dev auth provider authenticates anyone as any persona. "
|
|
79
|
+
+ "Set PORTAL_AUTH_DEV_ALLOW=true to explicitly opt in (local development only).",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const roster = parseDevRoster(env.PORTAL_AUTH_DEV_USERS);
|
|
84
|
+
const personasById = new Map(roster.map((persona) => [persona.id, persona]));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: "dev",
|
|
88
|
+
enabled: true,
|
|
89
|
+
displayName: "Dev Auth (testing)",
|
|
90
|
+
async authenticateRequest(token) {
|
|
91
|
+
if (typeof token !== "string" || !token.startsWith("dev:")) return null;
|
|
92
|
+
const persona = personasById.get(token.slice(4).trim().toLowerCase());
|
|
93
|
+
if (!persona) return null;
|
|
94
|
+
return {
|
|
95
|
+
provider: "dev",
|
|
96
|
+
subject: persona.id,
|
|
97
|
+
email: persona.email,
|
|
98
|
+
displayName: persona.displayName,
|
|
99
|
+
groups: [],
|
|
100
|
+
// Non-empty roles[] makes the authz engine decide from roles
|
|
101
|
+
// authoritatively — the same decision path Entra app roles use.
|
|
102
|
+
roles: [persona.role],
|
|
103
|
+
tenantId: null,
|
|
104
|
+
rawClaims: { dev: true, persona: persona.id },
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async getPublicConfig() {
|
|
108
|
+
return {
|
|
109
|
+
enabled: true,
|
|
110
|
+
provider: "dev",
|
|
111
|
+
displayName: "Dev Auth (testing)",
|
|
112
|
+
banner: DEV_BANNER,
|
|
113
|
+
client: {
|
|
114
|
+
users: roster.map(({ id, displayName, email, role }) => ({ id, displayName, email, role })),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/web/authz.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { OPERATIONS } from "pilotswarm-sdk/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ownership/visibility authorization for the portal runtime
|
|
5
|
+
* (docs/proposals/user-admin-security-model.md).
|
|
6
|
+
*
|
|
7
|
+
* The protocol table classifies every operation (`op.access`); this module
|
|
8
|
+
* evaluates the session-tree predicate for the classes that need a resource
|
|
9
|
+
* lookup. `runtime.call()` is the single enforcement point — both the
|
|
10
|
+
* generated /api/v1 routes and the legacy /api/rpc dispatcher land there.
|
|
11
|
+
*
|
|
12
|
+
* Dark launch: with AUTHZ_ENFORCE_OWNERSHIP=false (the default) every
|
|
13
|
+
* decision is computed and would-be denials are recorded in the authz audit
|
|
14
|
+
* table, but nothing is blocked. Flipping the env to true makes the same
|
|
15
|
+
* decisions enforcing — the audit stream is the pre-flip verification.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function parseBooleanEnv(value, defaultValue) {
|
|
19
|
+
if (value == null || value === "") return defaultValue;
|
|
20
|
+
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const VISIBILITY_VALUES = new Set(["private", "shared_read", "shared_write"]);
|
|
24
|
+
|
|
25
|
+
export function loadAuthzConfig(env = process.env) {
|
|
26
|
+
const rawDefault = String(env.SESSIONS_DEFAULT_VISIBILITY || "").trim().toLowerCase();
|
|
27
|
+
return {
|
|
28
|
+
enforce: parseBooleanEnv(env.AUTHZ_ENFORCE_OWNERSHIP, false),
|
|
29
|
+
defaultVisibility: VISIBILITY_VALUES.has(rawDefault) ? rawDefault : "private",
|
|
30
|
+
// "read" (default): system sessions are metadata/content-visible to
|
|
31
|
+
// every admitted user, interaction stays admin-only. "admin": hidden.
|
|
32
|
+
systemVisibility: String(env.SESSIONS_SYSTEM_VISIBILITY || "").trim().toLowerCase() === "admin" ? "admin" : "read",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeVisibility(value, fallback) {
|
|
37
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
38
|
+
return VISIBILITY_VALUES.has(normalized) ? normalized : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Methods reachable only through /api/rpc (not in the OPERATIONS table).
|
|
42
|
+
const RPC_ONLY_ACCESS = {
|
|
43
|
+
copyArtifact: "session:copy",
|
|
44
|
+
setArtifactPinned: "session:manage",
|
|
45
|
+
readArtifactBase64: "session:read",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ACCESS_BY_METHOD = new Map(OPERATIONS.map((op) => [op.name, { access: op.access, sessionParam: op.sessionParam || "sessionId" }]));
|
|
49
|
+
for (const [name, access] of Object.entries(RPC_ONLY_ACCESS)) {
|
|
50
|
+
if (!ACCESS_BY_METHOD.has(name)) ACCESS_BY_METHOD.set(name, { access, sessionParam: "sessionId" });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getMethodAccess(method) {
|
|
54
|
+
return ACCESS_BY_METHOD.get(method) || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function forbiddenError(message) {
|
|
58
|
+
return Object.assign(new Error(message), { code: "FORBIDDEN", status: 403 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function notFoundError() {
|
|
62
|
+
// Unreadable point-lookups report NOT_FOUND, not FORBIDDEN — an admitted
|
|
63
|
+
// caller must not be able to probe which session ids exist.
|
|
64
|
+
return Object.assign(new Error("Session not found."), { code: "NOT_FOUND", status: 404 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ownerLabel(snapshot) {
|
|
68
|
+
return snapshot?.owner?.displayName || snapshot?.owner?.email || snapshot?.owner?.subject || "another user";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The caller's relation to a session tree, recorded on message payloads and
|
|
73
|
+
* shown to the agent in multi-writer sessions.
|
|
74
|
+
*/
|
|
75
|
+
export function relationFor(snapshot, { isAdmin } = {}) {
|
|
76
|
+
if (snapshot?.viewerIsOwner) return "owner";
|
|
77
|
+
if (isAdmin) return "admin";
|
|
78
|
+
return "collaborator";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Evaluate one session-scoped access class against an access snapshot.
|
|
83
|
+
*
|
|
84
|
+
* @param accessClass "session:read" | "session:write" | "session:manage" | "session:destroy" | "session:share"
|
|
85
|
+
* @param snapshot result of getSessionAccess (null = missing/deleted session)
|
|
86
|
+
* @param opts { isAdmin, systemReadable }
|
|
87
|
+
* @returns {{ allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean }}
|
|
88
|
+
*/
|
|
89
|
+
export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true } = {}) {
|
|
90
|
+
if (!snapshot) {
|
|
91
|
+
// Missing/deleted session: let the underlying operation produce its
|
|
92
|
+
// own not-found; nothing to protect.
|
|
93
|
+
return { allowed: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isAdmin) {
|
|
97
|
+
// Admins pass everything; flag break-glass when this would have been
|
|
98
|
+
// invisible to a plain user in the same position.
|
|
99
|
+
const wouldBeInvisible = !snapshot.viewerIsOwner
|
|
100
|
+
&& !snapshot.isSystem
|
|
101
|
+
&& snapshot.visibility === "private"
|
|
102
|
+
&& !snapshot.viewerShareAccess;
|
|
103
|
+
return { allowed: true, breakGlass: wouldBeInvisible };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isRead = accessClass === "session:read";
|
|
107
|
+
|
|
108
|
+
if (snapshot.isSystem) {
|
|
109
|
+
// When system sessions are hidden from users, every class 404s so a
|
|
110
|
+
// write attempt can't confirm the session exists (review LOW-2).
|
|
111
|
+
if (!systemReadable) return { allowed: false, notFound: true };
|
|
112
|
+
if (isRead) return { allowed: true };
|
|
113
|
+
return { allowed: false, reason: "System sessions are managed by administrators." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const canRead = snapshot.viewerIsOwner
|
|
117
|
+
|| snapshot.visibility === "shared_read"
|
|
118
|
+
|| snapshot.visibility === "shared_write"
|
|
119
|
+
|| Boolean(snapshot.viewerShareAccess);
|
|
120
|
+
|
|
121
|
+
if (isRead) {
|
|
122
|
+
return canRead ? { allowed: true } : { allowed: false, notFound: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Anything beyond read on an unreadable session is also a 404 — the
|
|
126
|
+
// caller must not learn the session exists from the error shape.
|
|
127
|
+
if (!canRead) return { allowed: false, notFound: true };
|
|
128
|
+
|
|
129
|
+
if (accessClass === "session:write") {
|
|
130
|
+
const canWrite = snapshot.viewerIsOwner
|
|
131
|
+
|| snapshot.visibility === "shared_write"
|
|
132
|
+
|| snapshot.viewerShareAccess === "write";
|
|
133
|
+
return canWrite
|
|
134
|
+
? { allowed: true }
|
|
135
|
+
: { allowed: false, reason: `You have read access to this session; write access is required. Ask ${ownerLabel(snapshot)} for write access.` };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// manage / destroy / share: owner only (admin handled above).
|
|
139
|
+
return snapshot.viewerIsOwner
|
|
140
|
+
? { allowed: true }
|
|
141
|
+
: { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)}) or an admin can do this.` };
|
|
142
|
+
}
|