@intx/hub-sessions 0.1.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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @intx/hub-sessions
2
+
3
+ Session-orchestration substrate for the hub. Owns the sidecar
4
+ WebSocket router, the session service that provisions and tears
5
+ down agent sessions, the agent repository store, the event
6
+ collector registry, the asset service, and the skill kind handler.
7
+
8
+ Sits between `@intx/hub-api` (HTTP surface) and `@intx/hub-agent`
9
+ (sidecar orchestrator): HTTP routes call into the session service
10
+ to start an agent, the session service drives the sidecar router
11
+ to provision it on the connected sidecar, and event collectors
12
+ feed agent events back to the HTTP layer for observability.
13
+
14
+ `createSessionService` takes a `SessionServiceDeps` of
15
+ `sidecarRouter`, `agentRepoStore`, and an optional
16
+ `assetService` paired with a `db` handle for the asset manifest
17
+ inserts. `createHubSessionOrchestrator` takes a
18
+ `HubSessionOrchestratorDeps` of `events`, `router`, `db`,
19
+ `eventCollectors`, `grantStore`, and `agentRepoStore`. See the
20
+ exported types in `src/session-service.ts` and
21
+ `src/hub-session-orchestrator.ts` for the authoritative shapes;
22
+ `@intx/hub-api` is the in-tree consumer that wires these
23
+ factories together.
24
+
25
+ The package does not host HTTP routes itself; it exposes the
26
+ factories `@intx/hub-api` composes into the application.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@intx/hub-sessions",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/crypto-node": "0.0.0",
14
+ "@intx/db": "0.0.0",
15
+ "@intx/hub-common": "0.0.0",
16
+ "@intx/log": "0.0.0",
17
+ "@intx/mime": "0.0.0",
18
+ "@intx/pack-transport": "0.0.0",
19
+ "@intx/storage-isogit": "0.0.0",
20
+ "@intx/types": "0.0.0",
21
+ "arktype": "^2.1.29"
22
+ }
23
+ }
@@ -0,0 +1,310 @@
1
+ import { describe, test, expect, afterAll, beforeAll } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import git from "isomorphic-git";
6
+ import { generateKeyPair } from "@intx/crypto-node";
7
+ import { createDeployPack } from "@intx/storage-isogit";
8
+ import { createAgentRepoStore } from "./agent-repo";
9
+ import type { KeyPair } from "@intx/types/runtime";
10
+
11
+ const tempDirs: string[] = [];
12
+
13
+ async function makeTempDir(prefix: string): Promise<string> {
14
+ const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
15
+ tempDirs.push(d);
16
+ return d;
17
+ }
18
+
19
+ let signingKey: KeyPair;
20
+
21
+ beforeAll(async () => {
22
+ signingKey = await generateKeyPair();
23
+ });
24
+
25
+ afterAll(async () => {
26
+ for (const d of tempDirs.splice(0)) {
27
+ await fs.promises.rm(d, { recursive: true, force: true }).catch((_e) => {
28
+ /* best effort cleanup */
29
+ });
30
+ }
31
+ });
32
+
33
+ describe("AgentRepoStore", () => {
34
+ test("writeDeployTree creates a commit with the system prompt", async () => {
35
+ const dataDir = await makeTempDir("agent-repo-");
36
+ const store = createAgentRepoStore({ dataDir, signingKey });
37
+
38
+ const { commitSha } = await store.writeDeployTree("agent-1", {
39
+ systemPrompt: "You are a test agent.",
40
+ });
41
+
42
+ expect(commitSha).toMatch(/^[0-9a-f]{40}$/);
43
+
44
+ const repoDir = path.join(dataDir, "agents", "agent-1");
45
+ const prompt = await fs.promises.readFile(
46
+ path.join(repoDir, "deploy", "prompt.md"),
47
+ "utf-8",
48
+ );
49
+ expect(prompt).toBe("You are a test agent.");
50
+
51
+ const ref = await git.resolveRef({
52
+ fs,
53
+ dir: repoDir,
54
+ ref: "refs/heads/deploy",
55
+ });
56
+ expect(ref).toBe(commitSha);
57
+ });
58
+
59
+ test("writeDeployTree does not advance refs/heads/main", async () => {
60
+ const dataDir = await makeTempDir("agent-repo-ref-");
61
+ const store = createAgentRepoStore({ dataDir, signingKey });
62
+
63
+ await store.writeDeployTree("agent-ref", {
64
+ systemPrompt: "Ref test.",
65
+ });
66
+
67
+ const repoDir = path.join(dataDir, "agents", "agent-ref");
68
+ const mainLog = await git.log({ fs, dir: repoDir, ref: "refs/heads/main" });
69
+ const deployLog = await git.log({
70
+ fs,
71
+ dir: repoDir,
72
+ ref: "refs/heads/deploy",
73
+ });
74
+
75
+ // main should only have the init commit
76
+ expect(mainLog.length).toBe(1);
77
+ // deploy should have 2: init parent + deploy commit
78
+ expect(deployLog.length).toBe(2);
79
+ });
80
+
81
+ test("createDeployPack produces a valid packfile", async () => {
82
+ const dataDir = await makeTempDir("agent-repo-pack-");
83
+ const store = createAgentRepoStore({ dataDir, signingKey });
84
+
85
+ await store.writeDeployTree("agent-2", {
86
+ systemPrompt: "Pack test.",
87
+ });
88
+
89
+ const { pack, commitSha, ref } = await store.createDeployPack("agent-2");
90
+
91
+ expect(pack).toBeInstanceOf(Uint8Array);
92
+ expect(pack.length).toBeGreaterThan(0);
93
+ expect(commitSha).toMatch(/^[0-9a-f]{40}$/);
94
+ expect(ref).toBe("refs/heads/deploy");
95
+ });
96
+
97
+ test("receiveStatePack indexes objects and updates ref", async () => {
98
+ const dataDir = await makeTempDir("agent-repo-state-");
99
+ const store = createAgentRepoStore({ dataDir, signingKey });
100
+
101
+ await store.writeDeployTree("agent-3", {
102
+ systemPrompt: "State test.",
103
+ });
104
+
105
+ const sourceDir = await makeTempDir("state-source-");
106
+ await git.init({ fs, dir: sourceDir, defaultBranch: "main" });
107
+ await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true });
108
+ await fs.promises.writeFile(
109
+ path.join(sourceDir, "state", "turns.jsonl"),
110
+ '{"messages":[]}',
111
+ );
112
+ await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" });
113
+ const stateCommit = await git.commit({
114
+ fs,
115
+ dir: sourceDir,
116
+ message: "State snapshot",
117
+ author: { name: "test", email: "test@test" },
118
+ });
119
+
120
+ const { pack } = await createDeployPack(sourceDir, "refs/heads/main");
121
+
122
+ const stateRef = "refs/instances/test-instance";
123
+ await store.receiveStatePack(
124
+ { kind: "agent-state", id: "agent-3" },
125
+ pack,
126
+ stateRef,
127
+ stateCommit,
128
+ );
129
+
130
+ const repoDir = path.join(dataDir, "agents", "agent-3");
131
+ const resolved = await git.resolveRef({
132
+ fs,
133
+ dir: repoDir,
134
+ ref: stateRef,
135
+ });
136
+ expect(resolved).toBe(stateCommit);
137
+ });
138
+
139
+ test("receiveStatePack accepts packs with .gitignore alongside state", async () => {
140
+ const dataDir = await makeTempDir("agent-repo-gitignore-");
141
+ const store = createAgentRepoStore({ dataDir, signingKey });
142
+
143
+ await store.writeDeployTree("agent-gi", {
144
+ systemPrompt: "Gitignore test.",
145
+ });
146
+
147
+ const sourceDir = await makeTempDir("gitignore-source-");
148
+ await git.init({ fs, dir: sourceDir, defaultBranch: "main" });
149
+ await fs.promises.writeFile(path.join(sourceDir, ".gitignore"), "keys/\n");
150
+ await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true });
151
+ await fs.promises.writeFile(
152
+ path.join(sourceDir, "state", "turns.jsonl"),
153
+ "{}",
154
+ );
155
+ await git.add({ fs, dir: sourceDir, filepath: ".gitignore" });
156
+ await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" });
157
+ const stateCommit = await git.commit({
158
+ fs,
159
+ dir: sourceDir,
160
+ message: "State with gitignore",
161
+ author: { name: "test", email: "test@test" },
162
+ });
163
+
164
+ const { pack } = await createDeployPack(sourceDir, "refs/heads/main");
165
+ const stateRef = "refs/instances/gi-test";
166
+ await store.receiveStatePack(
167
+ { kind: "agent-state", id: "agent-gi" },
168
+ pack,
169
+ stateRef,
170
+ stateCommit,
171
+ );
172
+
173
+ const repoDir = path.join(dataDir, "agents", "agent-gi");
174
+ const resolved = await git.resolveRef({
175
+ fs,
176
+ dir: repoDir,
177
+ ref: stateRef,
178
+ });
179
+ expect(resolved).toBe(stateCommit);
180
+ });
181
+
182
+ test("receiveStatePack rejects packs with only .gitignore and no state/", async () => {
183
+ const dataDir = await makeTempDir("agent-repo-gitignore-only-");
184
+ const store = createAgentRepoStore({ dataDir, signingKey });
185
+
186
+ await store.writeDeployTree("agent-gio", {
187
+ systemPrompt: "Gitignore-only test.",
188
+ });
189
+
190
+ const sourceDir = await makeTempDir("gitignore-only-source-");
191
+ await git.init({ fs, dir: sourceDir, defaultBranch: "main" });
192
+ await fs.promises.writeFile(path.join(sourceDir, ".gitignore"), "keys/\n");
193
+ await git.add({ fs, dir: sourceDir, filepath: ".gitignore" });
194
+ const badCommit = await git.commit({
195
+ fs,
196
+ dir: sourceDir,
197
+ message: "Only gitignore",
198
+ author: { name: "test", email: "test@test" },
199
+ });
200
+
201
+ const { pack } = await createDeployPack(sourceDir, "refs/heads/main");
202
+
203
+ await expect(
204
+ store.receiveStatePack(
205
+ { kind: "agent-state", id: "agent-gio" },
206
+ pack,
207
+ "refs/instances/test",
208
+ badCommit,
209
+ ),
210
+ ).rejects.toThrow("path_violation");
211
+ });
212
+
213
+ test("receiveStatePack rejects packs with paths outside state/", async () => {
214
+ const dataDir = await makeTempDir("agent-repo-confined-");
215
+ const store = createAgentRepoStore({ dataDir, signingKey });
216
+
217
+ await store.writeDeployTree("agent-confined", {
218
+ systemPrompt: "Confinement test.",
219
+ });
220
+
221
+ const sourceDir = await makeTempDir("confined-source-");
222
+ await git.init({ fs, dir: sourceDir, defaultBranch: "main" });
223
+ await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true });
224
+ await fs.promises.mkdir(path.join(sourceDir, "deploy"), {
225
+ recursive: true,
226
+ });
227
+ await fs.promises.writeFile(
228
+ path.join(sourceDir, "state", "turns.jsonl"),
229
+ "{}",
230
+ );
231
+ await fs.promises.writeFile(
232
+ path.join(sourceDir, "deploy", "prompt.md"),
233
+ "evil",
234
+ );
235
+ await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" });
236
+ await git.add({ fs, dir: sourceDir, filepath: "deploy/prompt.md" });
237
+ const badCommit = await git.commit({
238
+ fs,
239
+ dir: sourceDir,
240
+ message: "Escaped confinement",
241
+ author: { name: "test", email: "test@test" },
242
+ });
243
+
244
+ const { pack } = await createDeployPack(sourceDir, "refs/heads/main");
245
+
246
+ await expect(
247
+ store.receiveStatePack(
248
+ { kind: "agent-state", id: "agent-confined" },
249
+ pack,
250
+ "refs/instances/test",
251
+ badCommit,
252
+ ),
253
+ ).rejects.toThrow("path_violation");
254
+ });
255
+
256
+ test("writeDeployTree produces a fresh commit when content changes", async () => {
257
+ const dataDir = await makeTempDir("agent-repo-idem-");
258
+ const store = createAgentRepoStore({ dataDir, signingKey });
259
+
260
+ const first = await store.writeDeployTree("agent-4", {
261
+ systemPrompt: "Version 1",
262
+ });
263
+
264
+ const second = await store.writeDeployTree("agent-4", {
265
+ systemPrompt: "Version 2",
266
+ });
267
+
268
+ expect(second.commitSha).not.toBe(first.commitSha);
269
+
270
+ const repoDir = path.join(dataDir, "agents", "agent-4");
271
+ const prompt = await fs.promises.readFile(
272
+ path.join(repoDir, "deploy", "prompt.md"),
273
+ "utf-8",
274
+ );
275
+ expect(prompt).toBe("Version 2");
276
+ });
277
+
278
+ test("hub repo does not contain state/ scaffolding", async () => {
279
+ const dataDir = await makeTempDir("agent-repo-nostate-");
280
+ const store = createAgentRepoStore({ dataDir, signingKey });
281
+
282
+ await store.writeDeployTree("agent-5", {
283
+ systemPrompt: "No state test.",
284
+ });
285
+
286
+ const repoDir = path.join(dataDir, "agents", "agent-5");
287
+ const stateExists = await fs.promises
288
+ .stat(path.join(repoDir, "state"))
289
+ .then(() => true)
290
+ .catch(() => false);
291
+ expect(stateExists).toBe(false);
292
+ });
293
+
294
+ test("rejects agent IDs with path traversal characters", () => {
295
+ const dataDir = "/tmp/never-created";
296
+ const store = createAgentRepoStore({ dataDir, signingKey });
297
+
298
+ expect(() =>
299
+ store.writeDeployTree("../../evil", {
300
+ systemPrompt: "x",
301
+ }),
302
+ ).toThrow("repo_id_invalid: ../../evil");
303
+
304
+ expect(() =>
305
+ store.writeDeployTree("agent@domain", {
306
+ systemPrompt: "x",
307
+ }),
308
+ ).toThrow("repo_id_invalid: agent@domain");
309
+ });
310
+ });
@@ -0,0 +1,165 @@
1
+ import { createSSHSignature } from "@intx/crypto-node";
2
+
3
+ import { createRepoStore } from "./repo-store";
4
+ import type { AuthorizeFn, RepoId, RepoStore } from "./repo-store";
5
+ import {
6
+ agentStateKindHandler,
7
+ agentStateAuthorize,
8
+ AGENT_STATE_DEPLOY_REF,
9
+ type AgentStateHubPrincipal,
10
+ type AgentStateSidecarPrincipal,
11
+ } from "./agent-state-kind";
12
+ import { skillKindHandler, skillAuthorize } from "./skill-kind";
13
+
14
+ export type DeployContent = {
15
+ systemPrompt: string;
16
+ };
17
+
18
+ export type AgentRepoStore = {
19
+ /**
20
+ * Write deploy content into the agent's hub-side repo and commit on
21
+ * refs/heads/deploy. Creates the repo if it doesn't exist.
22
+ *
23
+ * The caller is responsible for serializing calls per agent.
24
+ */
25
+ writeDeployTree(
26
+ agentId: string,
27
+ content: DeployContent,
28
+ ): Promise<{ commitSha: string }>;
29
+
30
+ /**
31
+ * Produce a packfile from the agent's current deploy ref.
32
+ */
33
+ createDeployPack(
34
+ agentId: string,
35
+ ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }>;
36
+
37
+ /**
38
+ * Receive and store a state pack from a sidecar. Indexes the pack
39
+ * objects and updates the ref without materializing a working tree.
40
+ *
41
+ * `repoId.kind` must be `"agent-state"` — this store is per-agent and
42
+ * does not generalize across kinds. The `repoId.id` is used as the
43
+ * agent address internally.
44
+ */
45
+ receiveStatePack(
46
+ repoId: RepoId,
47
+ pack: Uint8Array,
48
+ ref: string,
49
+ commitSha: string,
50
+ ): Promise<void>;
51
+
52
+ /** Resolve the current deploy ref SHA, or null if no deploy exists. */
53
+ getDeployRef(agentId: string): Promise<string | null>;
54
+
55
+ /** Raw 32-byte Ed25519 public key used to sign deploy commits. */
56
+ getSigningPublicKey(): Uint8Array;
57
+
58
+ /**
59
+ * Underlying kind-keyed substrate. Exposed so callers that need to
60
+ * operate on non-agent-state kinds (e.g. the asset service writing
61
+ * skill repos) can share the same on-disk root and signing key
62
+ * without spinning up a parallel RepoStore.
63
+ */
64
+ readonly repoStore: RepoStore;
65
+ };
66
+
67
+ export function createAgentRepoStore(config: {
68
+ dataDir: string;
69
+ signingKey: { privateKey: Uint8Array; publicKey: Uint8Array };
70
+ }): AgentRepoStore {
71
+ const { dataDir, signingKey } = config;
72
+
73
+ const authorize: AuthorizeFn = (principal, incomingRepoId, ref, action) => {
74
+ switch (incomingRepoId.kind) {
75
+ case "agent-state":
76
+ return agentStateAuthorize(principal, incomingRepoId, ref, action);
77
+ case "skill":
78
+ return skillAuthorize(principal, incomingRepoId, ref, action);
79
+ default: {
80
+ const _exhaustive: never = incomingRepoId.kind;
81
+ return {
82
+ allowed: false,
83
+ reason: `no authorize registered for kind: ${String(_exhaustive)}`,
84
+ };
85
+ }
86
+ }
87
+ };
88
+
89
+ // The substrate's signingCallback bridges the agent-repo store's
90
+ // raw Ed25519 keypair to the storage layer's per-payload SSHSIG
91
+ // signer. Skill asset genesis commits and agent-state deploy
92
+ // commits both flow through this signer so that signed commits
93
+ // round-trip through the smart-HTTP layer and verify under
94
+ // `git log --show-signature` and `git verify-commit`.
95
+ const signer = async (payload: string) =>
96
+ createSSHSignature(payload, signingKey.privateKey, signingKey.publicKey);
97
+
98
+ const store = createRepoStore({
99
+ dataDir,
100
+ signingKey,
101
+ handlers: {
102
+ "agent-state": agentStateKindHandler,
103
+ skill: skillKindHandler,
104
+ },
105
+ authorize,
106
+ signingCallback: () => signer,
107
+ });
108
+
109
+ const hub: AgentStateHubPrincipal = { kind: "hub" };
110
+
111
+ function repoId(agentId: string): RepoId {
112
+ return { kind: "agent-state", id: agentId };
113
+ }
114
+
115
+ return {
116
+ async writeDeployTree(agentId, content) {
117
+ const id = repoId(agentId);
118
+ const files: Record<string, string> = {
119
+ "deploy/prompt.md": content.systemPrompt,
120
+ };
121
+ return store.writeTree(hub, id, AGENT_STATE_DEPLOY_REF, {
122
+ files,
123
+ clearPrefix: "deploy/",
124
+ message: "Update deploy tree",
125
+ });
126
+ },
127
+
128
+ async createDeployPack(agentId) {
129
+ return store.createPack(hub, repoId(agentId), AGENT_STATE_DEPLOY_REF);
130
+ },
131
+
132
+ async receiveStatePack(incomingRepoId, pack, ref, commitSha) {
133
+ if (incomingRepoId.kind !== "agent-state") {
134
+ throw new Error(
135
+ `AgentRepoStore.receiveStatePack requires repoId.kind === "agent-state", got ${JSON.stringify(incomingRepoId.kind)}`,
136
+ );
137
+ }
138
+ const agentId = incomingRepoId.id;
139
+ const id = repoId(agentId);
140
+ const principal: AgentStateSidecarPrincipal = {
141
+ kind: "sidecar",
142
+ agentId,
143
+ };
144
+ const expectedOldSha = await store.resolveRef(principal, id, ref);
145
+ await store.receivePack(
146
+ principal,
147
+ id,
148
+ ref,
149
+ pack,
150
+ commitSha,
151
+ expectedOldSha,
152
+ );
153
+ },
154
+
155
+ async getDeployRef(agentId) {
156
+ return store.resolveRef(hub, repoId(agentId), AGENT_STATE_DEPLOY_REF);
157
+ },
158
+
159
+ getSigningPublicKey() {
160
+ return signingKey.publicKey;
161
+ },
162
+
163
+ repoStore: store,
164
+ };
165
+ }