@intx/hub-sessions 0.1.2 → 0.2.2

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 (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,185 @@
1
+ import { type } from "arktype";
2
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
3
+ import { UserPrincipal, } from "./repo-store/index.js";
4
+ const SidecarPrincipal = type({
5
+ kind: "'sidecar'",
6
+ agentId: "string",
7
+ });
8
+ export const AGENT_STATE_DEPLOY_REF = "refs/heads/deploy";
9
+ // Mirror of the sidecar's agent-state write surface. The isogit
10
+ // ContextStore (`packages/storage-isogit/src/store.ts`) writes exactly
11
+ // these top-level entries: `turns.jsonl`, `prompt.jsonl`,
12
+ // `response.jsonl`, `manifest.jsonl`, `metadata.json`, the `tool-output/`
13
+ // blob directory, and the `state/` directory holding `state/audit/` and
14
+ // `state/errors/`. `.gitignore` is seeded once by `initSidecarRepo`.
15
+ // Adding a new top-level write on the sidecar side requires adding the
16
+ // entry here in the same change — receivePack will silently
17
+ // `path_violation` the push otherwise. The receivePack path
18
+ // (`pack-receive.ts:validateTree`) walks every top-level tree entry —
19
+ // files and directories alike — through this allowlist; widening it
20
+ // to anything unowned by the sidecar's writer would let a malicious
21
+ // pack smuggle non-state content into the repo.
22
+ const ALLOWED_STATE_TOP_LEVEL = new Set([
23
+ "state",
24
+ ".gitignore",
25
+ "turns.jsonl",
26
+ "prompt.jsonl",
27
+ "response.jsonl",
28
+ "manifest.jsonl",
29
+ "metadata.json",
30
+ "tool-output",
31
+ ]);
32
+ const ALLOWED_DEPLOY_TOP_LEVEL = new Set(["deploy", ".gitignore"]);
33
+ export const agentStateKindHandler = {
34
+ kind: "agent-state",
35
+ directoryPrefix: "agents",
36
+ validatePush: ({ ref, topLevelTreePaths }) => {
37
+ // The deploy ref carries hub-authored prompt content under
38
+ // `deploy/`; every other ref carries sidecar-pushed agent state
39
+ // and must stay confined to the state-bearing allowlist.
40
+ const allowed = ref === AGENT_STATE_DEPLOY_REF
41
+ ? ALLOWED_DEPLOY_TOP_LEVEL
42
+ : ALLOWED_STATE_TOP_LEVEL;
43
+ const offender = topLevelTreePaths.find((p) => !allowed.has(p));
44
+ if (offender !== undefined) {
45
+ return {
46
+ ok: false,
47
+ reason: `tree contains disallowed top-level path: ${offender}`,
48
+ };
49
+ }
50
+ if (!topLevelTreePaths.some((p) => p !== ".gitignore")) {
51
+ return {
52
+ ok: false,
53
+ reason: "tree must include at least one state-bearing top-level entry",
54
+ };
55
+ }
56
+ return { ok: true };
57
+ },
58
+ onRefUpdated: () => {
59
+ // No consumer at this kind today; the substrate's hook surface is
60
+ // uniform across kinds and future kinds will use it.
61
+ },
62
+ };
63
+ export const agentStateAuthorize = (principal, repoId, ref, action) => {
64
+ if (principal.kind === "hub") {
65
+ // Full access at this kind. Hub-side reads (getDeployRef,
66
+ // createDeployPack) and writes (writeDeployTree) all flow through
67
+ // here, so changing this branch tightens behavior in non-obvious
68
+ // places.
69
+ return { allowed: true };
70
+ }
71
+ if (principal.kind === "sidecar") {
72
+ const parsed = SidecarPrincipal(principal);
73
+ if (parsed instanceof type.errors) {
74
+ return {
75
+ allowed: false,
76
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
77
+ };
78
+ }
79
+ if (repoId.kind !== "agent-state" || repoId.id !== parsed.agentId) {
80
+ return {
81
+ allowed: false,
82
+ reason: `sidecar ${parsed.agentId} cannot access ${repoId.kind}/${repoId.id}`,
83
+ };
84
+ }
85
+ switch (action) {
86
+ case "receivePack":
87
+ case "resolveRef":
88
+ return { allowed: true };
89
+ case "createPack":
90
+ if (ref !== AGENT_STATE_DEPLOY_REF) {
91
+ return {
92
+ allowed: false,
93
+ reason: `sidecar may only fetch ${AGENT_STATE_DEPLOY_REF}, not ${ref}`,
94
+ };
95
+ }
96
+ return { allowed: true };
97
+ case "init":
98
+ return {
99
+ allowed: false,
100
+ reason: "init is not authorize-gated for agent-state",
101
+ };
102
+ case "writeTree":
103
+ return {
104
+ allowed: false,
105
+ reason: `action ${action} is hub-only for agent-state`,
106
+ };
107
+ default: {
108
+ const _exhaustive = action;
109
+ return {
110
+ allowed: false,
111
+ reason: `unhandled action: ${String(_exhaustive)}`,
112
+ };
113
+ }
114
+ }
115
+ }
116
+ if (principal.kind === "user") {
117
+ // The route layer has already pre-resolved the grant verdict and
118
+ // attached it as `authz`. The substrate does NOT re-query the
119
+ // grant store here; it (a) checks the bearer-token's claims
120
+ // bound the requested (ref, action) and have not expired, and
121
+ // (b) sanity-checks that the pre-resolved verdict targets this
122
+ // exact resource and grant verb. Both gates must pass before the
123
+ // verdict's `effect` is honoured.
124
+ const parsed = UserPrincipal(principal);
125
+ if (parsed instanceof type.errors) {
126
+ return {
127
+ allowed: false,
128
+ reason: `user principal is malformed: ${parsed.summary}`,
129
+ };
130
+ }
131
+ if (repoId.kind !== "agent-state") {
132
+ return {
133
+ allowed: false,
134
+ reason: `user authorize received non-agent-state repo ${repoId.kind}/${repoId.id}`,
135
+ };
136
+ }
137
+ if (!parsed.tokenClaims.actions.includes(action)) {
138
+ return {
139
+ allowed: false,
140
+ reason: `token does not grant action ${action}`,
141
+ };
142
+ }
143
+ // `ref === "*"` is the substrate's sentinel for the bulk read
144
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
145
+ // layer's responsibility, so the bulk read is gated on action and
146
+ // expiry alone.
147
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
148
+ return {
149
+ allowed: false,
150
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
151
+ };
152
+ }
153
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
154
+ return {
155
+ allowed: false,
156
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
157
+ };
158
+ }
159
+ const expectedResource = `agent-state:${repoId.id}`;
160
+ if (parsed.authz.resource !== expectedResource) {
161
+ return {
162
+ allowed: false,
163
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
164
+ };
165
+ }
166
+ const expectedGrantVerb = repoActionToGrantVerb(action);
167
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
168
+ return {
169
+ allowed: false,
170
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
171
+ };
172
+ }
173
+ if (parsed.authz.effect === "allow") {
174
+ return { allowed: true };
175
+ }
176
+ return {
177
+ allowed: false,
178
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
179
+ };
180
+ }
181
+ return {
182
+ allowed: false,
183
+ reason: `unknown principal kind: ${principal.kind}`,
184
+ };
185
+ };
@@ -0,0 +1,123 @@
1
+ import { type DB } from "@intx/db";
2
+ import type { RepoKind } from "@intx/types/sidecar";
3
+ import type { InitRepoOpts, Principal, RepoStore, TreeContent } from "./repo-store/index.js";
4
+ export type Asset = {
5
+ id: string;
6
+ tenantId: string;
7
+ kind: RepoKind;
8
+ name: string;
9
+ displayName: string | null;
10
+ creatorPrincipalId: string | null;
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ };
14
+ export type AccessMode = "read-only" | "read-write";
15
+ export type AgentAsset = {
16
+ id: string;
17
+ agentId: string;
18
+ assetId: string;
19
+ ref: string;
20
+ accessMode: AccessMode;
21
+ createdAt: Date;
22
+ };
23
+ export type AgentAssetWithAsset = AgentAsset & {
24
+ asset: Pick<Asset, "id" | "tenantId" | "kind" | "name" | "displayName">;
25
+ };
26
+ export type CreateAssetParams = {
27
+ tenantId: string;
28
+ /** Accepted kinds: "skill", "package-registry", "workflow".
29
+ * "agent-state" is rejected because those repos are managed by the
30
+ * agent lifecycle, not the asset service. */
31
+ kind: RepoKind;
32
+ name: string;
33
+ displayName?: string;
34
+ creatorPrincipalId?: string;
35
+ /** Forwarded verbatim to `repoStore.initRepo`. Lets the REST route
36
+ * layer ship a per-asset `.gitignore` body (OS/editor cruft + build
37
+ * artefacts + `keys/`) in the genesis tree without the service
38
+ * encoding policy for any one consumer. When omitted, the substrate
39
+ * default body applies. */
40
+ initOpts?: InitRepoOpts;
41
+ };
42
+ export type PopulateAssetParams = {
43
+ assetId: string;
44
+ ref: string;
45
+ tree: TreeContent;
46
+ /** The principal authorized to write the kind. The substrate's
47
+ * authorize gate uses this; the kind handler also relies on it
48
+ * (e.g. skillAuthorize only permits `kind: "hub"` writes). */
49
+ principal: Principal;
50
+ };
51
+ export type AttachAssetParams = {
52
+ agentId: string;
53
+ assetId: string;
54
+ ref: string;
55
+ accessMode?: AccessMode;
56
+ };
57
+ export interface AssetService {
58
+ createAsset(params: CreateAssetParams): Promise<Asset>;
59
+ populateAsset(params: PopulateAssetParams): Promise<{
60
+ commitSha: string;
61
+ }>;
62
+ attachAsset(params: AttachAssetParams): Promise<AgentAsset>;
63
+ listAgentAssets(agentId: string): Promise<AgentAssetWithAsset[]>;
64
+ /**
65
+ * In-process blob read. Resolves the asset's row, then reads the blob
66
+ * at `path` from the commit pointed to by `ref` (defaults to
67
+ * `refs/heads/main`). Throws `AssetServiceError("not_found", ...)`
68
+ * when the asset, ref, or path do not exist.
69
+ */
70
+ readAssetBlob(params: ReadAssetBlobParams): Promise<Uint8Array>;
71
+ /**
72
+ * Enumerate the immediate child entry names at `dir` in the asset's
73
+ * commit tree. `dir` is a repo-root-relative POSIX directory path
74
+ * (no trailing slash, no leading slash); pass the empty string to
75
+ * list the root. Throws `AssetServiceError("not_found", ...)` when
76
+ * the asset or ref do not exist, or when `dir` is not a directory.
77
+ */
78
+ listAssetBlobs(params: ListAssetBlobsParams): Promise<string[]>;
79
+ }
80
+ export type ReadAssetBlobParams = {
81
+ assetId: string;
82
+ path: string;
83
+ /** Defaults to `refs/heads/main`. */
84
+ ref?: string;
85
+ };
86
+ export type ListAssetBlobsParams = {
87
+ assetId: string;
88
+ /** Empty string lists the tree root. */
89
+ dir: string;
90
+ /** Defaults to `refs/heads/main`. */
91
+ ref?: string;
92
+ };
93
+ /**
94
+ * Default ref the read API resolves against when callers do not
95
+ * supply one. The smart-HTTP route and the REST tarball routes both
96
+ * push to this ref so it carries the published-asset HEAD.
97
+ */
98
+ export declare const DEFAULT_ASSET_REF = "refs/heads/main";
99
+ /** Discriminator for AssetServiceError variants. Lets callers branch
100
+ * without instanceof gymnastics across the different error subclasses. */
101
+ export type AssetServiceErrorReason = "unsupported_kind" | "duplicate_asset" | "duplicate_attachment" | "invalid_name" | "invalid_reference" | "name_reserved" | "not_found" | "path_violation";
102
+ export declare class AssetServiceError extends Error {
103
+ readonly reason: AssetServiceErrorReason;
104
+ constructor(reason: AssetServiceErrorReason, message: string, cause?: unknown);
105
+ }
106
+ export declare function createAssetService(deps: {
107
+ db: DB["db"];
108
+ repoStore: RepoStore;
109
+ /**
110
+ * Names that the session service treats as configured HTTP registries
111
+ * when assembling the per-launch package-registry map. A
112
+ * `package-registry` asset whose name collides with one of these
113
+ * shadows the corresponding HTTP registry at session-launch time
114
+ * (asset wins on name collision). Creating such an asset is almost
115
+ * always an operator footgun — silently rerouting the public npm
116
+ * registry traffic to a tenant-owned asset — so reject the creation
117
+ * up front rather than letting the misroute surface later. The host
118
+ * threads in its `httpRegistries` keys; the asset service holds them
119
+ * statically because the registry config is loaded at hub boot and
120
+ * does not change at runtime.
121
+ */
122
+ reservedPackageRegistryNames?: ReadonlySet<string>;
123
+ }): AssetService;
@@ -0,0 +1,349 @@
1
+ // In-process service for creating and attaching skill-asset repos.
2
+ //
3
+ // Three responsibilities are layered here, mirroring the substrate's
4
+ // own layering (DB row, repo bookkeeping, content validation):
5
+ //
6
+ // createAsset inserts the asset row and initializes an empty
7
+ // skill-kind repo via RepoStore.initRepo.
8
+ // populateAsset drives RepoStore.writeTree, which runs the kind
9
+ // handler's validatePush before advancing the ref.
10
+ // Content rejections surface as AssetValidationError.
11
+ // attachAsset inserts an agent_asset row, surfacing the
12
+ // (agentId, assetId) uniqueness violation (which
13
+ // prevents the same asset being attached to one
14
+ // agent twice) as AssetAttachError.
15
+ //
16
+ // The factory is closure-based to match createAgentRepoStore and
17
+ // createRepoStore. There is no class because there is no per-instance
18
+ // mutable state — every method is a pure function over the deps.
19
+ import fs from "node:fs";
20
+ import { asc, eq } from "drizzle-orm";
21
+ import git from "isomorphic-git";
22
+ import { pgErrorCode, PG_UNIQUE_VIOLATION, PG_FOREIGN_KEY_VIOLATION, } from "@intx/db";
23
+ import { agentAsset as agentAssetTable, asset as assetTable, } from "@intx/db/schema";
24
+ import { generateId } from "@intx/hub-common";
25
+ import { getLogger } from "@intx/log";
26
+ const logger = getLogger(["hub-sessions", "asset-service"]);
27
+ /**
28
+ * Default ref the read API resolves against when callers do not
29
+ * supply one. The smart-HTTP route and the REST tarball routes both
30
+ * push to this ref so it carries the published-asset HEAD.
31
+ */
32
+ export const DEFAULT_ASSET_REF = "refs/heads/main";
33
+ // Asset names become the default workspace mountpath segment at
34
+ // session start (`skills/<asset.name>/`). The mountpath segment
35
+ // validator in applyAssetPack rejects anything outside a safe
36
+ // character set; validate at the createAsset boundary so a bad name
37
+ // fails at creation time rather than at materialization time. Names
38
+ // must be lowercase-kebab: lowercase letters, digits, hyphens, with
39
+ // no leading or trailing hyphen.
40
+ const ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
41
+ export class AssetServiceError extends Error {
42
+ reason;
43
+ constructor(reason, message, cause) {
44
+ super(message, cause === undefined ? undefined : { cause });
45
+ this.name = "AssetServiceError";
46
+ this.reason = reason;
47
+ }
48
+ }
49
+ function isAccessMode(value) {
50
+ return value === "read-only" || value === "read-write";
51
+ }
52
+ /**
53
+ * Reject malformed `dir` arguments at the `listAssetBlobs` boundary
54
+ * before any tree walk begins. The empty string is the documented
55
+ * "list the root" form; anything else must be a relative path with
56
+ * no leading slash, no trailing slash, no `..` segment, and no empty
57
+ * segments (no `//`). This mirrors the same rules that
58
+ * `validateClearPrefix` in the repo-store enforces on `clearPrefix`
59
+ * arguments, surfaced here as a path-violation error so the caller
60
+ * sees a structured rejection instead of a confusing "no directory
61
+ * at /tarballs/" miss when an absolute or `..`-bearing input slips
62
+ * past upstream validation.
63
+ */
64
+ function assertWellFormedListDir(dir) {
65
+ if (dir === "")
66
+ return;
67
+ if (dir === "/" || dir.startsWith("/")) {
68
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir must be a relative path or "" for root; got ${JSON.stringify(dir)}`);
69
+ }
70
+ if (dir.endsWith("/")) {
71
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir must not end with a trailing slash; got ${JSON.stringify(dir)}`);
72
+ }
73
+ const segments = dir.split("/");
74
+ for (const segment of segments) {
75
+ if (segment === "") {
76
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir contains an empty segment ("//"): ${JSON.stringify(dir)}`);
77
+ }
78
+ if (segment === "..") {
79
+ throw new AssetServiceError("path_violation", `listAssetBlobs: dir contains a ".." segment: ${JSON.stringify(dir)}`);
80
+ }
81
+ }
82
+ }
83
+ function rowToAsset(row) {
84
+ // The schema stores `kind` as plain text. RepoKind is an arktype
85
+ // enum; narrow by exhaustive check so an out-of-band kind value
86
+ // loudly fails rather than silently mistypes the returned shape.
87
+ let narrowed;
88
+ switch (row.kind) {
89
+ case "agent-state":
90
+ narrowed = "agent-state";
91
+ break;
92
+ case "skill":
93
+ narrowed = "skill";
94
+ break;
95
+ case "package-registry":
96
+ narrowed = "package-registry";
97
+ break;
98
+ case "workflow":
99
+ narrowed = "workflow";
100
+ break;
101
+ default:
102
+ throw new Error(`asset row ${row.id} has unknown kind ${JSON.stringify(row.kind)}`);
103
+ }
104
+ return {
105
+ id: row.id,
106
+ tenantId: row.tenantId,
107
+ kind: narrowed,
108
+ name: row.name,
109
+ displayName: row.displayName,
110
+ creatorPrincipalId: row.creatorPrincipalId,
111
+ createdAt: row.createdAt,
112
+ updatedAt: row.updatedAt,
113
+ };
114
+ }
115
+ function rowToAgentAsset(row) {
116
+ if (!isAccessMode(row.accessMode)) {
117
+ throw new Error(`agent_asset row ${row.id} has unknown accessMode ${JSON.stringify(row.accessMode)}`);
118
+ }
119
+ return {
120
+ id: row.id,
121
+ agentId: row.agentId,
122
+ assetId: row.assetId,
123
+ ref: row.ref,
124
+ accessMode: row.accessMode,
125
+ createdAt: row.createdAt,
126
+ };
127
+ }
128
+ export function createAssetService(deps) {
129
+ const { db, repoStore } = deps;
130
+ const reservedPackageRegistryNames = deps.reservedPackageRegistryNames ?? new Set();
131
+ async function createAsset(params) {
132
+ if (params.kind !== "skill" &&
133
+ params.kind !== "package-registry" &&
134
+ params.kind !== "workflow") {
135
+ throw new AssetServiceError("unsupported_kind", `createAsset rejects kind ${JSON.stringify(params.kind)}: the asset service handles "skill", "package-registry", and "workflow" assets; other repo kinds are managed by their respective subsystems`);
136
+ }
137
+ if (!ASSET_NAME_PATTERN.test(params.name)) {
138
+ throw new AssetServiceError("invalid_name", `createAsset rejects name ${JSON.stringify(params.name)}: must be lowercase-kebab (letters, digits, hyphens; no leading or trailing hyphen)`);
139
+ }
140
+ if (params.kind === "package-registry" &&
141
+ reservedPackageRegistryNames.has(params.name)) {
142
+ // Session-launch builds the per-launch registry map by iterating
143
+ // package-registry assets first and HTTP registries second, with
144
+ // an asset-wins-on-collision rule. A `package-registry` asset
145
+ // named after a configured HTTP registry would silently shadow
146
+ // that registry for every session that resolves through this
147
+ // tenant — almost certainly an operator misconfig, not an
148
+ // intended override. Reject the creation so the operator sees
149
+ // the collision at intent time instead of debugging an
150
+ // unexpected reroute later.
151
+ throw new AssetServiceError("name_reserved", `createAsset rejects name ${JSON.stringify(params.name)}: it collides with a configured HTTP registry of the same name and would silently shadow it at session launch`);
152
+ }
153
+ const id = generateId("asset");
154
+ const now = new Date();
155
+ const insertRow = {
156
+ id,
157
+ tenantId: params.tenantId,
158
+ kind: params.kind,
159
+ name: params.name,
160
+ displayName: params.displayName ?? null,
161
+ creatorPrincipalId: params.creatorPrincipalId ?? null,
162
+ createdAt: now,
163
+ updatedAt: now,
164
+ };
165
+ // Init the repo before the row insert so a repo-init failure leaves
166
+ // no orphan row in the database. initRepo is idempotent and the
167
+ // generated id is locally unique, so a follow-up failure of the row
168
+ // insert (duplicate, FK violation, etc.) leaves at worst an empty
169
+ // unreferenced repo directory — harmless and reused on retry of a
170
+ // logically identical asset. The asset-service db handle does not
171
+ // expose transactions in the current narrowing, so this ordering is
172
+ // the safest cross-cutting fix without widening the dep surface.
173
+ //
174
+ // Note: each failed insert with a fresh `id` does leave its own
175
+ // orphan repo directory on disk. The directories carry no asset
176
+ // row and no traffic, so they are inert; a periodic GC walker
177
+ // that drops on-disk repos with no matching row is a follow-up.
178
+ await repoStore.initRepo({ kind: params.kind, id }, params.initOpts);
179
+ let inserted;
180
+ try {
181
+ const rows = await db.insert(assetTable).values(insertRow).returning();
182
+ const row = rows[0];
183
+ if (row === undefined) {
184
+ throw new Error("insert into asset returned no rows");
185
+ }
186
+ inserted = row;
187
+ }
188
+ catch (err) {
189
+ if (pgErrorCode(err) === PG_UNIQUE_VIOLATION) {
190
+ throw new AssetServiceError("duplicate_asset", `asset (tenantId=${params.tenantId}, kind=${params.kind}, name=${params.name}) already exists`, err);
191
+ }
192
+ throw err;
193
+ }
194
+ logger.debug `created asset ${id} (kind=${params.kind}, tenant=${params.tenantId}, name=${params.name})`;
195
+ return rowToAsset(inserted);
196
+ }
197
+ async function populateAsset(params) {
198
+ // The asset row carries `kind`. We must read it before writing so
199
+ // the RepoId is shaped correctly; without it, callers could write
200
+ // against the wrong kind handler.
201
+ const row = await db.query.asset.findFirst({
202
+ where: eq(assetTable.id, params.assetId),
203
+ });
204
+ if (row === undefined) {
205
+ throw new AssetServiceError("not_found", `populateAsset: asset ${params.assetId} not found`);
206
+ }
207
+ const assetRow = rowToAsset(row);
208
+ try {
209
+ return await repoStore.writeTree(params.principal, { kind: assetRow.kind, id: assetRow.id }, params.ref, params.tree);
210
+ }
211
+ catch (err) {
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ if (msg.startsWith("path_violation:")) {
214
+ throw new AssetServiceError("path_violation", msg, err);
215
+ }
216
+ throw err;
217
+ }
218
+ }
219
+ async function attachAsset(params) {
220
+ const id = generateId("agentAsset");
221
+ const accessMode = params.accessMode ?? "read-only";
222
+ const insertRow = {
223
+ id,
224
+ agentId: params.agentId,
225
+ assetId: params.assetId,
226
+ ref: params.ref,
227
+ accessMode,
228
+ createdAt: new Date(),
229
+ };
230
+ let inserted;
231
+ try {
232
+ const rows = await db
233
+ .insert(agentAssetTable)
234
+ .values(insertRow)
235
+ .returning();
236
+ const row = rows[0];
237
+ if (row === undefined) {
238
+ throw new Error("insert into agent_asset returned no rows");
239
+ }
240
+ inserted = row;
241
+ }
242
+ catch (err) {
243
+ if (pgErrorCode(err) === PG_UNIQUE_VIOLATION) {
244
+ throw new AssetServiceError("duplicate_attachment", `agent_asset (agentId=${params.agentId}, assetId=${params.assetId}) already attached`, err);
245
+ }
246
+ if (pgErrorCode(err) === PG_FOREIGN_KEY_VIOLATION) {
247
+ throw new AssetServiceError("invalid_reference", `agent_asset (agentId=${params.agentId}, assetId=${params.assetId}) references a missing agent or asset`, err);
248
+ }
249
+ throw err;
250
+ }
251
+ return rowToAgentAsset(inserted);
252
+ }
253
+ async function listAgentAssets(agentId) {
254
+ // Order by (createdAt, id) so the row sequence is stable across reads
255
+ // — the available_skills stanza and pack fan-out both depend on a
256
+ // deterministic order, and Postgres does not guarantee one without
257
+ // an explicit orderBy.
258
+ const rows = await db
259
+ .select({
260
+ agentAsset: agentAssetTable,
261
+ asset: assetTable,
262
+ })
263
+ .from(agentAssetTable)
264
+ .innerJoin(assetTable, eq(agentAssetTable.assetId, assetTable.id))
265
+ .where(eq(agentAssetTable.agentId, agentId))
266
+ .orderBy(asc(agentAssetTable.createdAt), asc(agentAssetTable.id));
267
+ return rows.map((row) => {
268
+ const aa = rowToAgentAsset(row.agentAsset);
269
+ const a = rowToAsset(row.asset);
270
+ return {
271
+ ...aa,
272
+ asset: {
273
+ id: a.id,
274
+ tenantId: a.tenantId,
275
+ kind: a.kind,
276
+ name: a.name,
277
+ displayName: a.displayName,
278
+ },
279
+ };
280
+ });
281
+ }
282
+ async function resolveAssetRowOrThrow(assetId, label) {
283
+ const row = await db.query.asset.findFirst({
284
+ where: eq(assetTable.id, assetId),
285
+ });
286
+ if (row === undefined) {
287
+ throw new AssetServiceError("not_found", `${label}: asset ${assetId} not found`);
288
+ }
289
+ return rowToAsset(row);
290
+ }
291
+ async function resolveCommitTreeOid(asset, ref, label) {
292
+ const dir = repoStore.getRepoDir({ kind: asset.kind, id: asset.id });
293
+ let commitSha;
294
+ try {
295
+ commitSha = await git.resolveRef({ fs, dir, ref });
296
+ }
297
+ catch (cause) {
298
+ throw new AssetServiceError("not_found", `${label}: asset ${asset.id} ref ${ref} not resolvable`, cause);
299
+ }
300
+ const { commit } = await git.readCommit({ fs, dir, oid: commitSha });
301
+ return { dir, treeOid: commit.tree };
302
+ }
303
+ async function readAssetBlob(params) {
304
+ const ref = params.ref ?? DEFAULT_ASSET_REF;
305
+ const asset = await resolveAssetRowOrThrow(params.assetId, "readAssetBlob");
306
+ const { dir, treeOid } = await resolveCommitTreeOid(asset, ref, "readAssetBlob");
307
+ try {
308
+ const { blob } = await git.readBlob({
309
+ fs,
310
+ dir,
311
+ oid: treeOid,
312
+ filepath: params.path,
313
+ });
314
+ return blob;
315
+ }
316
+ catch (cause) {
317
+ throw new AssetServiceError("not_found", `readAssetBlob: asset ${params.assetId} has no blob at ${JSON.stringify(params.path)} on ref ${ref}`, cause);
318
+ }
319
+ }
320
+ async function listAssetBlobs(params) {
321
+ assertWellFormedListDir(params.dir);
322
+ const ref = params.ref ?? DEFAULT_ASSET_REF;
323
+ const asset = await resolveAssetRowOrThrow(params.assetId, "listAssetBlobs");
324
+ const { dir, treeOid } = await resolveCommitTreeOid(asset, ref, "listAssetBlobs");
325
+ if (params.dir === "") {
326
+ const { tree } = await git.readTree({ fs, dir, oid: treeOid });
327
+ return tree.filter((e) => e.type === "blob").map((e) => e.path);
328
+ }
329
+ let currentOid = treeOid;
330
+ for (const segment of params.dir.split("/")) {
331
+ const { tree } = await git.readTree({ fs, dir, oid: currentOid });
332
+ const entry = tree.find((e) => e.path === segment);
333
+ if (entry === undefined || entry.type !== "tree") {
334
+ throw new AssetServiceError("not_found", `listAssetBlobs: asset ${params.assetId} has no directory at ${JSON.stringify(params.dir)} on ref ${ref}`);
335
+ }
336
+ currentOid = entry.oid;
337
+ }
338
+ const { tree } = await git.readTree({ fs, dir, oid: currentOid });
339
+ return tree.filter((e) => e.type === "blob").map((e) => e.path);
340
+ }
341
+ return {
342
+ createAsset,
343
+ populateAsset,
344
+ attachAsset,
345
+ listAgentAssets,
346
+ readAssetBlob,
347
+ listAssetBlobs,
348
+ };
349
+ }
@@ -0,0 +1,21 @@
1
+ export type AvailableSkillEntry = {
2
+ /** Qualified skill identifier in the form `<asset.name>/<skill-name>`. */
3
+ qualifiedName: string;
4
+ /** SKILL.md frontmatter description, verbatim. */
5
+ description: string;
6
+ /** Workspace-relative path the agent's `read_file` should target,
7
+ * shaped like `workspace/<mountPath>/<skill-name>/`. */
8
+ workspacePath: string;
9
+ };
10
+ /**
11
+ * Render the `<available_skills>` stanza appended to the agent's
12
+ * system prompt. Returns the empty string when `entries` is empty —
13
+ * an empty `<available_skills></available_skills>` wrapper would be
14
+ * misleading noise for agents with no skills attached.
15
+ *
16
+ * Values are XML-escaped at the boundary. The skill kind handler
17
+ * already rejects descriptions containing literal `<` or `>` so the
18
+ * `&` escape is the practical case in production; the others are
19
+ * defensive.
20
+ */
21
+ export declare function buildAvailableSkillsStanza(entries: AvailableSkillEntry[]): string;