pilotswarm-sdk 0.5.29 → 0.5.31

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 (98) hide show
  1. package/api/index.d.ts +40 -0
  2. package/api/index.js +8 -0
  3. package/api/src/api-client.js +10 -1
  4. package/api/src/http-api-transport.js +22 -2
  5. package/api/src/protocol.js +25 -0
  6. package/api/src/session-authz.d.ts +76 -0
  7. package/api/src/session-authz.js +240 -0
  8. package/dist/agent-fqn.d.ts +72 -0
  9. package/dist/agent-fqn.d.ts.map +1 -0
  10. package/dist/agent-fqn.js +137 -0
  11. package/dist/agent-fqn.js.map +1 -0
  12. package/dist/agent-loader.d.ts +8 -0
  13. package/dist/agent-loader.d.ts.map +1 -1
  14. package/dist/agent-loader.js +45 -6
  15. package/dist/agent-loader.js.map +1 -1
  16. package/dist/agent-manager-tools.d.ts +56 -0
  17. package/dist/agent-manager-tools.d.ts.map +1 -0
  18. package/dist/agent-manager-tools.js +581 -0
  19. package/dist/agent-manager-tools.js.map +1 -0
  20. package/dist/agent-package-diff.d.ts +83 -0
  21. package/dist/agent-package-diff.d.ts.map +1 -0
  22. package/dist/agent-package-diff.js +201 -0
  23. package/dist/agent-package-diff.js.map +1 -0
  24. package/dist/agent-package-format.d.ts +175 -0
  25. package/dist/agent-package-format.d.ts.map +1 -0
  26. package/dist/agent-package-format.js +812 -0
  27. package/dist/agent-package-format.js.map +1 -0
  28. package/dist/agent-package-import-fetch.d.ts +68 -0
  29. package/dist/agent-package-import-fetch.d.ts.map +1 -0
  30. package/dist/agent-package-import-fetch.js +156 -0
  31. package/dist/agent-package-import-fetch.js.map +1 -0
  32. package/dist/agent-package-import-policy.d.ts +133 -0
  33. package/dist/agent-package-import-policy.d.ts.map +1 -0
  34. package/dist/agent-package-import-policy.js +347 -0
  35. package/dist/agent-package-import-policy.js.map +1 -0
  36. package/dist/agent-package-installer.d.ts +64 -0
  37. package/dist/agent-package-installer.d.ts.map +1 -0
  38. package/dist/agent-package-installer.js +174 -0
  39. package/dist/agent-package-installer.js.map +1 -0
  40. package/dist/agent-package-service.d.ts +61 -0
  41. package/dist/agent-package-service.d.ts.map +1 -0
  42. package/dist/agent-package-service.js +181 -0
  43. package/dist/agent-package-service.js.map +1 -0
  44. package/dist/cms-migrations.d.ts.map +1 -1
  45. package/dist/cms-migrations.js +1451 -0
  46. package/dist/cms-migrations.js.map +1 -1
  47. package/dist/cms.d.ts +317 -0
  48. package/dist/cms.d.ts.map +1 -1
  49. package/dist/cms.js +337 -0
  50. package/dist/cms.js.map +1 -1
  51. package/dist/index.d.ts +14 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +12 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/inspect-tools.d.ts +68 -8
  56. package/dist/inspect-tools.d.ts.map +1 -1
  57. package/dist/inspect-tools.js +281 -21
  58. package/dist/inspect-tools.js.map +1 -1
  59. package/dist/management-client.d.ts +35 -1
  60. package/dist/management-client.d.ts.map +1 -1
  61. package/dist/management-client.js +41 -3
  62. package/dist/management-client.js.map +1 -1
  63. package/dist/mcp-loader.d.ts.map +1 -1
  64. package/dist/mcp-loader.js +17 -1
  65. package/dist/mcp-loader.js.map +1 -1
  66. package/dist/session-manager.d.ts +63 -0
  67. package/dist/session-manager.d.ts.map +1 -1
  68. package/dist/session-manager.js +147 -1
  69. package/dist/session-manager.js.map +1 -1
  70. package/dist/session-proxy.d.ts +9 -1
  71. package/dist/session-proxy.d.ts.map +1 -1
  72. package/dist/session-proxy.js +116 -12
  73. package/dist/session-proxy.js.map +1 -1
  74. package/dist/types.d.ts +27 -0
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js.map +1 -1
  77. package/dist/web/api-connection.d.ts +8 -0
  78. package/dist/web/api-connection.d.ts.map +1 -1
  79. package/dist/web/api-connection.js +2 -0
  80. package/dist/web/api-connection.js.map +1 -1
  81. package/dist/web/generated-op-methods.d.ts +917 -0
  82. package/dist/web/generated-op-methods.d.ts.map +1 -0
  83. package/dist/web/generated-op-methods.js +255 -0
  84. package/dist/web/generated-op-methods.js.map +1 -0
  85. package/dist/web/web-management-client.d.ts +193 -8
  86. package/dist/web/web-management-client.d.ts.map +1 -1
  87. package/dist/web/web-management-client.js +170 -12
  88. package/dist/web/web-management-client.js.map +1 -1
  89. package/dist/worker.d.ts +74 -0
  90. package/dist/worker.d.ts.map +1 -1
  91. package/dist/worker.js +347 -11
  92. package/dist/worker.js.map +1 -1
  93. package/package.json +5 -4
  94. package/plugins/mgmt/agents/pilotswarm.agent.md +2 -2
  95. package/plugins/mgmt/skills/cost-latency-analysis/SKILL.md +1 -1
  96. package/plugins/mgmt/skills/graph-debug/SKILL.md +4 -4
  97. package/plugins/mgmt/skills/orchestration-session-lifecycle/SKILL.md +1 -1
  98. package/plugins/mgmt/agents/agent-tuner.agent.md +0 -316
package/api/index.d.ts CHANGED
@@ -84,3 +84,43 @@ export declare class HttpApiTransport {
84
84
  stop(): Promise<void>;
85
85
  [method: string]: any;
86
86
  }
87
+
88
+ // ── Session-tree access predicate (src/session-authz.js) ──────────
89
+ // Pure; shared by the portal runtime and the worker's agent tools so the
90
+ // "may this principal touch this session?" answer has ONE implementation.
91
+
92
+ export interface SessionAccessSnapshot {
93
+ rootSessionId: string;
94
+ isSystem: boolean;
95
+ visibility: "private" | "shared_read" | "shared_write";
96
+ owner: { displayName?: string | null; email?: string | null; subject?: string | null } | null;
97
+ viewerIsOwner: boolean;
98
+ viewerShareAccess: "read" | "write" | null;
99
+ }
100
+
101
+ export interface SessionAccessDecision {
102
+ allowed: boolean;
103
+ /** Report NOT_FOUND rather than FORBIDDEN: an admitted caller must not be able to probe which session ids exist. */
104
+ notFound?: boolean;
105
+ reason?: string;
106
+ /** An admin reached something a plain user in the same position could not see. Audit it. */
107
+ breakGlass?: boolean;
108
+ }
109
+
110
+ export type SessionAccessClass =
111
+ | "session:read" | "session:write" | "session:manage" | "session:destroy" | "session:share";
112
+
113
+ export declare const SESSION_VISIBILITY_VALUES: readonly string[];
114
+ export declare function normalizeVisibility(value: unknown, fallback: string): string;
115
+ export declare function systemSessionsReadable(env?: Record<string, string | undefined>): boolean;
116
+ export declare function relationFor(snapshot: SessionAccessSnapshot | null, opts?: { isAdmin?: boolean }): "owner" | "admin" | "collaborator";
117
+ export declare function evaluateSessionAccess(
118
+ accessClass: SessionAccessClass,
119
+ snapshot: SessionAccessSnapshot | null,
120
+ opts?: { isAdmin?: boolean; systemReadable?: boolean },
121
+ ): SessionAccessDecision;
122
+ /** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
123
+ export declare function evaluateArchiveAccess(
124
+ snapshot: SessionAccessSnapshot | null,
125
+ opts?: { isAdmin?: boolean },
126
+ ): SessionAccessDecision;
package/api/index.js CHANGED
@@ -12,5 +12,13 @@ export {
12
12
  artifactDownloadPath,
13
13
  ApiError,
14
14
  } from "./src/protocol.js";
15
+ export {
16
+ SESSION_VISIBILITY_VALUES,
17
+ normalizeVisibility,
18
+ systemSessionsReadable,
19
+ relationFor,
20
+ evaluateSessionAccess,
21
+ evaluateArchiveAccess,
22
+ } from "./src/session-authz.js";
15
23
  export { ApiClient } from "./src/api-client.js";
16
24
  export { HttpApiTransport } from "./src/http-api-transport.js";
@@ -68,7 +68,16 @@ export class ApiClient {
68
68
  // ── HTTP ────────────────────────────────────────────────────────────
69
69
 
70
70
  async authHeaders(extra = {}) {
71
- const token = await this.getAccessToken();
71
+ // Bounded: a token getter that neither resolves nor rejects (wedged
72
+ // silent renewal) must not hang every REST call behind it — proceed
73
+ // tokenless after 12s and let the server's 401 drive re-auth.
74
+ const token = await Promise.race([
75
+ Promise.resolve().then(() => this.getAccessToken()).catch(() => null),
76
+ new Promise((resolve) => {
77
+ const t = setTimeout(() => resolve(null), 12_000);
78
+ if (typeof t?.unref === "function") t.unref();
79
+ }),
80
+ ]);
72
81
  const headers = { ...extra };
73
82
  if (token) headers.authorization = `Bearer ${token}`;
74
83
  return headers;
@@ -64,8 +64,28 @@ export class HttpApiTransport {
64
64
  }
65
65
 
66
66
  async listCreatableAgents() {
67
- return this.bootstrap?.creatableAgents || this.api.call("listCreatableAgents");
68
- }
67
+ // Always fetch: the server unions baked agents with viewer-scoped
68
+ // registry packages, so the bootstrap snapshot (baked-only, captured
69
+ // at page load) would hide packages and go stale across publishes.
70
+ try {
71
+ return await this.api.call("listCreatableAgents");
72
+ } catch {
73
+ return this.bootstrap?.creatableAgents || [];
74
+ }
75
+ }
76
+
77
+ // ── Agent packages (docs/proposals/agent-packages.md) ────────
78
+ listAgentPackages() { return this.api.call("listAgentPackages"); }
79
+ getAgentPackage(name) { return this.api.call("getAgentPackage", { name }); }
80
+ getAgentPackageTree(name, semver) { return this.api.call("getAgentPackageTree", { name, ...(semver ? { semver } : {}) }); }
81
+ getAgentPackageFile(name, semver, filePath) { return this.api.call("getAgentPackageFile", { name, filePath, ...(semver ? { semver } : {}) }); }
82
+ uploadAgentPackage(files, scope) { return this.api.call("uploadAgentPackage", { files, scope }); }
83
+ listAgentWorkerState() { return this.api.call("listAgentWorkerState"); }
84
+ listWorkers() { return this.api.call("listWorkers"); }
85
+ setAgentPackageScope(name, scope) { return this.api.call("setAgentPackageScope", { name, scope }); }
86
+ setAgentPackageEnabled(name, enabled) { return this.api.call("setAgentPackageEnabled", { name, enabled }); }
87
+ pinAgentPackageVersion(name, semver) { return this.api.call("pinAgentPackageVersion", { name, semver }); }
88
+ deleteAgentPackage(name) { return this.api.call("deleteAgentPackage", { name }); }
69
89
 
70
90
  getSessionCreationPolicy() {
71
91
  return this.bootstrap?.sessionCreationPolicy || null;
@@ -86,6 +86,16 @@ export const OPERATIONS = [
86
86
  { name: "downloadArtifact", access: "session:read", method: "GET", path: "/sessions/:sessionId/artifacts/:filename/text", params: { sessionId: path("sessionId"), filename: path("filename") }, summary: "Artifact content as text (JSON envelope). Binary: GET …/download." },
87
87
  { name: "uploadArtifact", access: "session:write", method: "PUT", path: "/sessions/:sessionId/artifacts/:filename", params: { sessionId: path("sessionId"), filename: path("filename"), content: body(), contentType: body(), contentEncoding: body() }, summary: "Upload artifact content (base64 for binary; 2 MB JSON limit)." },
88
88
  { name: "deleteArtifact", access: "session:manage", method: "DELETE", path: "/sessions/:sessionId/artifacts/:filename", params: { sessionId: path("sessionId"), filename: path("filename") }, summary: "Delete an artifact." },
89
+ // These three were dispatchable (runtime.js) and access-classified
90
+ // (authz.js RPC_ONLY_ACCESS) but reachable only through the legacy
91
+ // /api/rpc path — every ApiClient.call() of them threw "Unknown API
92
+ // operation" client-side, which silently broke the MCP artifact
93
+ // read-base64/copy/pin actions in web mode. Table rows give them
94
+ // generated routes with the same authz (session:copy gates
95
+ // fromSessionId for read + toSessionId for write by param name).
96
+ { name: "readArtifactBase64", access: "session:read", method: "GET", path: "/sessions/:sessionId/artifacts/:filename/base64", params: { sessionId: path("sessionId"), filename: path("filename"), maxBytes: query("number") }, summary: "Artifact content as base64 (JSON envelope; maxBytes caps the read, truncated flag set when hit)." },
97
+ { name: "copyArtifact", access: "session:copy", method: "POST", path: "/artifacts/copy", params: { fromSessionId: body(), fromFilename: body(), toSessionId: body(), toFilename: body() }, summary: "Copy an artifact across sessions (read access on the source, write on the target)." },
98
+ { name: "setArtifactPinned", access: "session:manage", method: "PUT", path: "/sessions/:sessionId/artifacts/:filename/pinned", params: { sessionId: path("sessionId"), filename: path("filename"), pinned: body() }, summary: "Pin/unpin an artifact (pinned artifacts survive retention sweeps)." },
89
99
 
90
100
  // ── Management: sessions ────────────────────────────────────────────
91
101
  { name: "listSessionsPage", access: "session:list", method: "GET", path: "/management/sessions", params: { limit: query("number"), cursor: query("json"), includeDeleted: query("boolean") }, summary: "Keyset-paginated session listing." },
@@ -181,6 +191,21 @@ export const OPERATIONS = [
181
191
  { name: "listCreatableAgents", access: "authed", method: "GET", path: "/agents", summary: "Agents sessions can be created for." },
182
192
  { name: "getSessionCreationPolicy", access: "authed", method: "GET", path: "/session-creation-policy", summary: "Session creation policy." },
183
193
 
194
+ // ── Agent packages (docs/proposals/agent-packages.md) ───────────────
195
+ // Fixed segments (sources / upload / worker-state) are registered BEFORE
196
+ // the :name routes — Express matches in table order.
197
+ { name: "listAgentPackages", access: "authed", method: "GET", path: "/agent-packages", summary: "Agent packages visible to the caller: shared + own user-scope (admins see all)." },
198
+ { name: "uploadAgentPackage", access: "authed", method: "POST", path: "/agent-packages/upload", params: { files: body(), scope: body() }, summary: "Publish a package from inline files ([{path, contentBase64}], ≤ 2 MB total); validates, canonically packs, and registers as the caller." },
199
+ { name: "listAgentWorkerState", access: "fleet:admin", method: "GET", path: "/agent-packages/worker-state", admin: true, summary: "Per-worker installed package state (fleet adoption). Hard admin gate: the installed map enumerates every package name, including user-scope ones. [admin]" },
200
+ { name: "listWorkers", access: "fleet:admin", method: "GET", path: "/workers", admin: true, summary: "Worker registry (0040): every registered worker with pool, lifecycle phase, liveness, write-once info, health snapshot, and per-domain state. Hard admin gate. [admin]" },
201
+ { name: "getAgentPackage", access: "authed", method: "GET", path: "/agent-packages/:name", params: { name: path("name") }, summary: "One package with its full version history." },
202
+ { name: "getAgentPackageTree", access: "authed", method: "GET", path: "/agent-packages/:name/tree", params: { name: path("name"), semver: query("string") }, summary: "File tree of the package tarball (workspace viewer). Defaults to the active version." },
203
+ { name: "getAgentPackageFile", access: "authed", method: "GET", path: "/agent-packages/:name/file", params: { name: path("name"), semver: query("string"), filePath: query("string") }, summary: "One file from the package tarball (preview; text size-capped, binary flagged)." },
204
+ { name: "setAgentPackageScope", access: "authed", method: "PUT", path: "/agent-packages/:name/scope", params: { name: path("name"), scope: body() }, summary: "Promote (shared) or demote (user). Creator or admin; running agents unaffected." },
205
+ { name: "setAgentPackageEnabled", access: "authed", method: "PUT", path: "/agent-packages/:name/enabled", params: { name: path("name"), enabled: body() }, summary: "Enable/disable a package fleet-wide. Creator or admin." },
206
+ { name: "pinAgentPackageVersion", access: "authed", method: "PUT", path: "/agent-packages/:name/active", params: { name: path("name"), semver: body() }, summary: "Pin the active version (rollback). Creator or admin; fleet converges on the next epoch poll." },
207
+ { name: "deleteAgentPackage", access: "authed", method: "DELETE", path: "/agent-packages/:name", params: { name: path("name") }, summary: "Delete a package: every version and its artifacts. Creator or admin. Live sessions using its agents fail resolution on their next turn." },
208
+
184
209
  // ── Current user profile ────────────────────────────────────────────
185
210
  { name: "getCurrentUserProfile", access: "authed", method: "GET", path: "/me/profile", summary: "Profile of the authenticated principal." },
186
211
  { name: "setCurrentUserProfileSettings", access: "authed", method: "PATCH", path: "/me/profile/settings", params: { settings: body() }, summary: "Replace profile settings." },
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Types for the shared session-tree access predicate.
3
+ *
4
+ * The implementation is plain ESM JS (it is imported by the portal, which is
5
+ * not TypeScript), so the worker needs this to consume it without `any`.
6
+ * Typing matters more than usual here: `allowed` and `notFound` mean opposite
7
+ * things to a caller, and an untyped decision object is one typo away from a
8
+ * gate that always passes.
9
+ */
10
+
11
+ export interface SessionAccessSnapshot {
12
+ rootSessionId?: string;
13
+ isSystem: boolean;
14
+ visibility: "private" | "shared_read" | "shared_write";
15
+ owner: { displayName?: string | null; email?: string | null; subject?: string | null } | null;
16
+ viewerIsOwner: boolean;
17
+ viewerShareAccess: "read" | "write" | null;
18
+ }
19
+
20
+ export interface SessionAccessDecision {
21
+ allowed: boolean;
22
+ /** Report NOT_FOUND rather than FORBIDDEN — an admitted caller must not be able to probe which session ids exist. */
23
+ notFound?: boolean;
24
+ reason?: string;
25
+ /** An admin reached something a plain user in the same position could not see. Audit it. */
26
+ breakGlass?: boolean;
27
+ }
28
+
29
+ export type SessionAccessClass =
30
+ | "session:read"
31
+ | "session:write"
32
+ | "session:manage"
33
+ | "session:destroy"
34
+ | "session:share";
35
+
36
+ export declare const SESSION_VISIBILITY_VALUES: readonly string[];
37
+
38
+ export declare function normalizeVisibility(value: unknown, fallback: string): string;
39
+
40
+ /** Whether ordinary users may READ system sessions (SESSIONS_SYSTEM_VISIBILITY; default true). */
41
+ export declare function systemSessionsReadable(env?: Record<string, string | undefined>): boolean;
42
+
43
+ export declare function relationFor(
44
+ snapshot: SessionAccessSnapshot | null,
45
+ opts?: { isAdmin?: boolean },
46
+ ): "owner" | "admin" | "collaborator";
47
+
48
+ export declare function evaluateSessionAccess(
49
+ accessClass: SessionAccessClass,
50
+ snapshot: SessionAccessSnapshot | null,
51
+ opts?: { isAdmin?: boolean; systemReadable?: boolean },
52
+ ): SessionAccessDecision;
53
+
54
+ /** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
55
+ export declare function evaluateArchiveAccess(
56
+ snapshot: SessionAccessSnapshot | null,
57
+ opts?: { isAdmin?: boolean },
58
+ ): SessionAccessDecision;
59
+
60
+ /** A sign-in role observation read from the users table. */
61
+ export interface RoleObservation {
62
+ role?: string | null;
63
+ seenAt?: Date | null;
64
+ }
65
+
66
+ /** How old a recorded sign-in role may be and still confer privilege. */
67
+ export declare const ROLE_OBSERVATION_MAX_AGE_MS: number;
68
+
69
+ /**
70
+ * Does a recorded sign-in role still make this principal an administrator?
71
+ * Every unknown resolves to `false`.
72
+ */
73
+ export declare function evaluateRoleObservation(
74
+ observation: RoleObservation | null,
75
+ opts?: { now?: number; maxAgeMs?: number; principal?: { provider?: string; subject?: string } | null },
76
+ ): { isAdmin: boolean; reason?: string };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Session-tree access predicate — the ONE implementation, shared by every
3
+ * surface that has to answer "may this principal touch this session?".
4
+ *
5
+ * It lived in `packages/app/web/authz.js` and served the portal alone. The
6
+ * worker now needs the same answer: agent tools that read sessions must reach
7
+ * exactly what their session's OWNER could reach, and a second implementation
8
+ * would be a second thing to get wrong. So the pure half moved here, into the
9
+ * dependency both sides already have. The portal re-exports it, unchanged.
10
+ *
11
+ * Deliberately pure: no I/O, no env, no database. Callers fetch the access
12
+ * snapshot (`catalog.getSessionAccess`) and pass it in. That is what makes it
13
+ * usable from an Express route and from inside a tool handler.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} SessionAccessSnapshot
20
+ * @property {string} rootSessionId
21
+ * @property {boolean} isSystem
22
+ * @property {"private"|"shared_read"|"shared_write"} visibility
23
+ * @property {{displayName?: string|null, email?: string|null, subject?: string|null}|null} owner
24
+ * @property {boolean} viewerIsOwner
25
+ * @property {"read"|"write"|null} viewerShareAccess
26
+ */
27
+
28
+ export const SESSION_VISIBILITY_VALUES = Object.freeze(["private", "shared_read", "shared_write"]);
29
+
30
+ const VISIBILITY_SET = new Set(SESSION_VISIBILITY_VALUES);
31
+
32
+ export function normalizeVisibility(value, fallback) {
33
+ const normalized = String(value || "").trim().toLowerCase();
34
+ return VISIBILITY_SET.has(normalized) ? normalized : fallback;
35
+ }
36
+
37
+ /**
38
+ * Are system sessions readable by ordinary users on this deployment?
39
+ *
40
+ * The portal reads `SESSIONS_SYSTEM_VISIBILITY` for exactly this; the worker
41
+ * must reach the same answer or the same user would see the PilotSwarm root
42
+ * in their session list but be told it does not exist by an agent. Default
43
+ * "read" — hiding them is the opt-in.
44
+ */
45
+ export function systemSessionsReadable(env = (typeof process !== "undefined" ? process.env : {})) {
46
+ return String(env?.SESSIONS_SYSTEM_VISIBILITY || "").trim().toLowerCase() !== "admin";
47
+ }
48
+
49
+ function ownerLabel(snapshot) {
50
+ return snapshot?.owner?.displayName || snapshot?.owner?.email || snapshot?.owner?.subject || "another user";
51
+ }
52
+
53
+ /**
54
+ * The caller's relation to a session tree, recorded on message payloads and
55
+ * shown to the agent in multi-writer sessions.
56
+ */
57
+ export function relationFor(snapshot, { isAdmin } = {}) {
58
+ if (snapshot?.viewerIsOwner) return "owner";
59
+ if (isAdmin) return "admin";
60
+ return "collaborator";
61
+ }
62
+
63
+ /**
64
+ * Evaluate one session-scoped access class against an access snapshot.
65
+ *
66
+ * @param {"session:read"|"session:write"|"session:manage"|"session:destroy"|"session:share"} accessClass
67
+ * @param {SessionAccessSnapshot|null} snapshot result of getSessionAccess (null = missing/deleted)
68
+ * @param {{isAdmin?: boolean, systemReadable?: boolean}} [opts]
69
+ * @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
70
+ */
71
+ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true } = {}) {
72
+ if (!snapshot) {
73
+ // Missing/deleted session: let the underlying operation produce its
74
+ // own not-found; nothing to protect.
75
+ return { allowed: true };
76
+ }
77
+
78
+ if (isAdmin) {
79
+ // Admins pass everything; flag break-glass when this would have been
80
+ // invisible to a plain user in the same position.
81
+ const wouldBeInvisible = !snapshot.viewerIsOwner
82
+ && !snapshot.isSystem
83
+ && snapshot.visibility === "private"
84
+ && !snapshot.viewerShareAccess;
85
+ return { allowed: true, breakGlass: wouldBeInvisible };
86
+ }
87
+
88
+ const isRead = accessClass === "session:read";
89
+
90
+ if (snapshot.isSystem) {
91
+ // When system sessions are hidden from users, every class 404s so a
92
+ // write attempt can't confirm the session exists (review LOW-2).
93
+ if (!systemReadable) return { allowed: false, notFound: true };
94
+ if (isRead) return { allowed: true };
95
+ return { allowed: false, reason: "System sessions are managed by administrators." };
96
+ }
97
+
98
+ const canRead = snapshot.viewerIsOwner
99
+ || snapshot.visibility === "shared_read"
100
+ || snapshot.visibility === "shared_write"
101
+ || Boolean(snapshot.viewerShareAccess);
102
+
103
+ if (isRead) {
104
+ return canRead ? { allowed: true } : { allowed: false, notFound: true };
105
+ }
106
+
107
+ // Anything beyond read on an unreadable session is also a 404 — the
108
+ // caller must not learn the session exists from the error shape.
109
+ if (!canRead) return { allowed: false, notFound: true };
110
+
111
+ if (accessClass === "session:write") {
112
+ const canWrite = snapshot.viewerIsOwner
113
+ || snapshot.visibility === "shared_write"
114
+ || snapshot.viewerShareAccess === "write";
115
+ return canWrite
116
+ ? { allowed: true }
117
+ : { allowed: false, reason: `You have read access to this session; write access is required. Ask ${ownerLabel(snapshot)} for write access.` };
118
+ }
119
+
120
+ // manage / destroy / share: owner only (admin handled above).
121
+ return snapshot.viewerIsOwner
122
+ ? { allowed: true }
123
+ : { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)}) or an admin can do this.` };
124
+ }
125
+
126
+ /**
127
+ * Archive (dehydrated session tar) access — owner or admin ONLY, never a
128
+ * share. Deliberately NOT `evaluateSessionAccess("session:read", …)`.
129
+ *
130
+ * The tar is raw session state: pre-compaction history the UI no longer
131
+ * displays, full tool-call arguments (which can carry secrets a user pasted),
132
+ * internal scratch. Sharing a session shares a *view* of a conversation; it
133
+ * was never an offer of the substrate underneath. See the Agent Manager
134
+ * proposal, §15 A3.
135
+ *
136
+ * @param {SessionAccessSnapshot|null} snapshot
137
+ * @param {{isAdmin?: boolean}} [opts]
138
+ * @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
139
+ */
140
+ export function evaluateArchiveAccess(snapshot, { isAdmin = false } = {}) {
141
+ if (!snapshot) return { allowed: true };
142
+ if (isAdmin) {
143
+ // Reading someone else's raw session state is exactly what the
144
+ // authz_audit table calls a break-glass read.
145
+ return { allowed: true, breakGlass: !snapshot.viewerIsOwner };
146
+ }
147
+ if (snapshot.viewerIsOwner) return { allowed: true };
148
+ if (snapshot.viewerShareAccess || snapshot.visibility !== "private") {
149
+ return {
150
+ allowed: false,
151
+ reason:
152
+ "Session archives are available to the session owner and administrators only. "
153
+ + "A share grants the conversation, not the raw session state behind it.",
154
+ };
155
+ }
156
+ return { allowed: false, notFound: true };
157
+ }
158
+
159
+ /**
160
+ * How old a recorded sign-in role may be and still confer privilege.
161
+ *
162
+ * The stored role is an OBSERVATION — the last thing the identity provider
163
+ * told the portal — so it decays. An active portal user re-confirms it every
164
+ * few minutes; a principal who has not authenticated within this window is
165
+ * treated as a plain user, which is the fail-closed direction.
166
+ *
167
+ * The failure this bounds is concrete: an admin is demoted in the identity
168
+ * provider and never opens the portal again, while a cron-driven session of
169
+ * theirs keeps firing turns. Without a ceiling that session keeps fleet reach
170
+ * forever, because nothing would ever contradict the stored `admin`.
171
+ */
172
+ export const ROLE_OBSERVATION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
173
+
174
+ /**
175
+ * The only principal that may ever hold the `anonymous` role.
176
+ *
177
+ * `anonymous` is issued by `authorizePrincipal(null, policy)` — the
178
+ * auth-disabled branch — and that branch pairs it with
179
+ * `createNoAuthUnknownPrincipal()`, which is this exact identity. So the
180
+ * binding below is not a new restriction; it is the existing invariant,
181
+ * written down where it is relied upon.
182
+ */
183
+ const ANONYMOUS_PRINCIPAL = { provider: "none", subject: "unknown" };
184
+
185
+ /**
186
+ * Does a recorded sign-in role still make this principal an administrator?
187
+ *
188
+ * Separate from `evaluateSessionAccess` on purpose: that answers "may this
189
+ * viewer touch this session", this answers "is this viewer privileged at all".
190
+ * It is a pure function so the fail-closed paths can be tested without a
191
+ * database, a worker, or a clock.
192
+ *
193
+ * `anonymous` counts as admin, because an auth-disabled deployment is a
194
+ * trusted one and the portal already grants it full access there. A worker
195
+ * that disagreed would make agents quietly more restricted than the UI beside
196
+ * them.
197
+ *
198
+ * But `anonymous` is a NAME being trusted to GRANT, which is the one direction
199
+ * the proposal's own A10 rule says is never safe — a forged name can only put
200
+ * you inside a denylist, never outside one, and this is the opposite shape. So
201
+ * it is admitted only for the single principal that can legitimately carry it.
202
+ * Nothing today can write `anonymous` for a named user; this makes that a
203
+ * property of the code rather than a property of three other files staying
204
+ * the way they are.
205
+ *
206
+ * Every unknown resolves to `false`: no role, an unrecognized role, a missing
207
+ * timestamp, or an observation past the ceiling.
208
+ *
209
+ * @param {{role?: string|null, seenAt?: Date|null}|null} observation
210
+ * @param {{now?: number, maxAgeMs?: number, principal?: {provider?: string, subject?: string}|null}} [opts]
211
+ * @returns {{isAdmin: boolean, reason?: string}}
212
+ */
213
+ export function evaluateRoleObservation(observation, { now = Date.now(), maxAgeMs = ROLE_OBSERVATION_MAX_AGE_MS, principal = null } = {}) {
214
+ const role = observation?.role ?? null;
215
+ if (role !== "admin" && role !== "anonymous") {
216
+ return { isAdmin: false, reason: role ? `role is '${role}'` : "no role recorded" };
217
+ }
218
+ if (role === "anonymous"
219
+ && !(principal
220
+ && principal.provider === ANONYMOUS_PRINCIPAL.provider
221
+ && principal.subject === ANONYMOUS_PRINCIPAL.subject)) {
222
+ return {
223
+ isAdmin: false,
224
+ reason: "the anonymous role belongs to the auth-disabled principal only",
225
+ };
226
+ }
227
+ const seenAt = observation?.seenAt ?? null;
228
+ if (!(seenAt instanceof Date) || Number.isNaN(seenAt.getTime())) {
229
+ return { isAdmin: false, reason: "role has no observation timestamp" };
230
+ }
231
+ // A future timestamp (clock skew between portal and worker) is not
232
+ // suspicious enough to refuse, but it must never extend the window
233
+ // either — which comparing the elapsed age against the ceiling already
234
+ // guarantees, since a negative age is under it.
235
+ const age = now - seenAt.getTime();
236
+ if (age > maxAgeMs) {
237
+ return { isAdmin: false, reason: `role observation is stale (${Math.floor(age / 3600_000)}h old)` };
238
+ }
239
+ return { isAdmin: true };
240
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Fully-qualified agent/package names.
3
+ *
4
+ * <package> bare — own enabled copy, then shared
5
+ * __shared:<package>[:<semver>] the deployment-wide copy, explicitly
6
+ * <owner>:<package>[:<semver>] a named owner's copy
7
+ *
8
+ * ── The collision this module exists to manage ──────────────────────────
9
+ *
10
+ * `a:b` was ALREADY meaningful before namespaces: the agent resolver reads it
11
+ * as `namespace:agentName` (a plugin's `plugin.json` name). §9 wants the same
12
+ * syntax to mean `owner:package`. Both readings are legitimate and neither can
13
+ * be dropped without breaking something.
14
+ *
15
+ * So this parser never guesses. A two-segment name is reported as AMBIGUOUS —
16
+ * carrying both readings — and the caller resolves in a defined order
17
+ * (namespace first, preserving today's behaviour, then owner). Only forms that
18
+ * are structurally unmistakable are classified outright:
19
+ *
20
+ * - a `__`-prefixed first segment is a RESERVED SENTINEL, so `__shared:x`
21
+ * can only ever be an FQN;
22
+ * - three segments end in a semver, which namespaces never had.
23
+ *
24
+ * ── Why `__` is reserved ────────────────────────────────────────────────
25
+ *
26
+ * `__shared` has to be unforgeable: if a user could register the subject
27
+ * `__shared`, or publish a package under it, they would capture every
28
+ * unqualified reference that meant "the deployment's copy". Reserving the
29
+ * whole prefix (not just the one token) keeps future sentinels mintable at
30
+ * zero cost — and per §15 A10, a name may DENY but must never GRANT, which is
31
+ * exactly how this is used: `__shared` selects a scope, it never confers rights.
32
+ *
33
+ * @module
34
+ */
35
+ /** The reserved sentinel for the deployment-wide namespace. */
36
+ export declare const SHARED_SENTINEL = "__shared";
37
+ /** Any name starting with this is reserved for the platform. */
38
+ export declare const RESERVED_PREFIX = "__";
39
+ export type AgentFqnKind = "bare" | "shared" | "owner" | "ambiguous" | "invalid";
40
+ export interface AgentFqn {
41
+ kind: AgentFqnKind;
42
+ /** The package/agent name — always present except for `invalid`. */
43
+ name: string;
44
+ /** Owner segment as written, for `owner` (and the owner reading of `ambiguous`). */
45
+ ownerRef?: string;
46
+ /** Namespace reading of an ambiguous two-segment name. */
47
+ namespaceRef?: string;
48
+ /** Pinned version, when the caller named one. */
49
+ semver?: string;
50
+ /** Why the input was rejected. Present only for `invalid`. */
51
+ reason?: string;
52
+ }
53
+ /**
54
+ * Is this name reserved for the platform?
55
+ *
56
+ * Used at BOTH gates the design names: package publish, and user registration.
57
+ * Checking only one of them would leave the sentinel forgeable from the other
58
+ * side.
59
+ */
60
+ export declare function isReservedName(value: string): boolean;
61
+ export declare function isSemver(value: string): boolean;
62
+ /**
63
+ * Parse a possibly-qualified agent or package reference.
64
+ *
65
+ * Never throws: an unusable input comes back as `kind: "invalid"` with a
66
+ * reason, because these strings arrive from models and users alike and a
67
+ * parser that throws on hostile input is a denial-of-service in the turn loop.
68
+ */
69
+ export declare function parseAgentFqn(raw: string): AgentFqn;
70
+ /** Render a parsed reference back to its canonical string, for display. */
71
+ export declare function formatAgentFqn(fqn: AgentFqn): string;
72
+ //# sourceMappingURL=agent-fqn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-fqn.d.ts","sourceRoot":"","sources":["../src/agent-fqn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,+DAA+D;AAC/D,eAAO,MAAM,eAAe,aAAa,CAAC;AAE1C,gEAAgE;AAChE,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,GAAG,SAAS,CAAC;AAEjF,MAAM,WAAW,QAAQ;IACrB,IAAI,EAAE,YAAY,CAAC;IACnB,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAErD;AAUD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE/C;AAID;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CA0DnD;AAED,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAYpD"}