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.
Files changed (42) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +12 -0
  3. package/mcp/dist/src/context.d.ts +13 -0
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +20 -0
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/tools/capabilities.d.ts +3 -0
  8. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/capabilities.js +8 -0
  10. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +114 -0
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/package.json +3 -2
  15. package/tui/src/app.js +19 -2
  16. package/tui/src/auth/cli.js +13 -0
  17. package/tui/src/node-sdk-transport.js +53 -8
  18. package/tui/tui-splash-mobile.txt +5 -7
  19. package/tui/tui-splash.txt +13 -9
  20. package/ui/core/src/commands.js +2 -0
  21. package/ui/core/src/controller.js +275 -6
  22. package/ui/core/src/history.js +19 -1
  23. package/ui/core/src/reducer.js +4 -0
  24. package/ui/core/src/selectors.js +139 -14
  25. package/ui/core/src/themes/helpers.js +4 -0
  26. package/ui/react/src/components.js +95 -6
  27. package/ui/react/src/web-app.js +555 -117
  28. package/web/api/router.js +7 -6
  29. package/web/api/ws.js +9 -0
  30. package/web/auth/index.js +5 -0
  31. package/web/auth/providers/dev.js +119 -0
  32. package/web/authz.js +142 -0
  33. package/web/dist/assets/index-BnxC8cNG.js +24 -0
  34. package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
  35. package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
  36. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  37. package/web/dist/index.html +3 -4
  38. package/web/runtime.js +453 -9
  39. package/web/server.js +2 -2
  40. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  41. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  42. 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. This is the
155
- // API's only per-route role check; every other op shares the
156
- // binary admission gate (requireAuth above).
157
- if (op.admin && !isAdminAuth(req.auth)) {
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
+ }