@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.
- package/LICENSE +176 -0
- package/README.md +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- 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;
|