@frockbot/plugin-package-publisher 0.0.0 → 0.1.1

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/frockbot.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "id": "package-publisher",
4
+ "displayName": "Package Publisher",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "computer": ">=0.0.1",
9
+ "shell": ">=0.0.1",
10
+ "ui-theme": ">=0.0.1"
11
+ },
12
+ "contributions": {
13
+ "backend": [
14
+ { "entry": "./backend", "host": "gateway" },
15
+ { "entry": "./user", "host": "user" }
16
+ ],
17
+ "runtime": { "entry": "./agent" },
18
+ "client": {
19
+ "entry": "./client",
20
+ "mounts": [{ "slot": "frockbot.user-settings-sections", "order": 20 }],
21
+ "outlets": []
22
+ }
23
+ },
24
+ "permissions": ["package:publish"]
25
+ }
package/package.json CHANGED
@@ -1,14 +1,52 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-package-publisher",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent.ts",
9
+ "./backend": "./src/backend.ts",
10
+ "./user": "./src/user.ts",
11
+ "./client": "./src/client/index.ts",
12
+ "./manifest": "./src/manifest.ts",
13
+ "./shared": "./src/shared.ts",
14
+ "./frockbot.json": "./frockbot.json",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "frockbot": {
18
+ "manifest": "./frockbot.json"
19
+ },
20
+ "scripts": {
21
+ "test": "bun test src",
22
+ "build": "vite build",
23
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
24
+ },
25
+ "dependencies": {
26
+ "@frockbot/kernel-contracts": "0.1.1",
27
+ "@frockbot/plugin-prompt": "0.1.1",
28
+ "@frockbot/plugin-tools": "0.1.1",
29
+ "@frockbot/client-core": "0.1.1",
30
+ "@frockbot/client-ui": "0.1.1",
31
+ "@frockbot/plugin-shell": "0.1.1",
32
+ "cordis": "4.0.0-rc.8",
33
+ "vue": "3.5.41"
34
+ },
35
+ "devDependencies": {
36
+ "@frockbot/computer-core": "0.1.1",
37
+ "@frockbot/plugin-testkit": "0.1.1",
38
+ "@types/bun": "1.4.0",
39
+ "@vitejs/plugin-vue": "6.0.8",
40
+ "typescript": "5.9.3",
41
+ "vite": "8.2.2",
42
+ "vue-tsc": "3.3.10"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
6
47
  "repository": {
7
48
  "type": "git",
8
49
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
50
  "directory": "packages/plugin-package-publisher"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
51
  }
14
52
  }
@@ -0,0 +1,159 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
3
+ import { ToolRegistry } from "@frockbot/plugin-tools";
4
+ import {
5
+ ComputerRegistry,
6
+ type ComputerProvider,
7
+ } from "@frockbot/computer-core";
8
+ import {
9
+ createPluginHarness,
10
+ verifyPluginPackage,
11
+ } from "@frockbot/plugin-testkit";
12
+ import manifest from "../frockbot.json" with { type: "json" };
13
+ import packageJson from "../package.json" with { type: "json" };
14
+ import { createPackagePublisherAgentPlugin } from "./agent.js";
15
+ import type {
16
+ PackagePublicationReceiptV1,
17
+ PackageRevisionHistoryV1,
18
+ } from "./shared.js";
19
+
20
+ const history: PackageRevisionHistoryV1 = {
21
+ schemaVersion: 1,
22
+ revision: 1,
23
+ activePackageRevision: 1,
24
+ revisions: [
25
+ {
26
+ packageRevision: 1,
27
+ applicationHash: "sha256:one",
28
+ publishedAt: "2026-09-01T00:00:00.000Z",
29
+ checks: [{ name: "test", status: "passed" }],
30
+ },
31
+ ],
32
+ };
33
+
34
+ async function execute(
35
+ tools: Pick<ToolRegistry, "prepare" | "executePrepared">,
36
+ name: string,
37
+ input: unknown,
38
+ ): Promise<{ content: string; isError: boolean }> {
39
+ const context = {
40
+ botId: "bot-1",
41
+ agentId: "bot-1",
42
+ compositionGenerationId: "bootstrap",
43
+ turnType: "chat" as const,
44
+ effectId: "tool:1:1:0",
45
+ sessionId: "session-1",
46
+ signal: new AbortController().signal,
47
+ };
48
+ const prepared = await tools.prepare(
49
+ { id: crypto.randomUUID(), name, input },
50
+ context,
51
+ );
52
+ if (prepared.kind !== "ready") throw new Error("tool was denied");
53
+ return tools.executePrepared(prepared, context);
54
+ }
55
+
56
+ describe("Package Publisher Agent contribution", () => {
57
+ test("lets any Bot list, publish, and roll back its User's shared setup", async () => {
58
+ const commands: unknown[] = [];
59
+ const executedCommands: string[] = [];
60
+ const active = (commandId: string): PackagePublicationReceiptV1 => ({
61
+ schemaVersion: 1,
62
+ commandId,
63
+ status: "active",
64
+ revision: 2,
65
+ packageRevision: 1,
66
+ applicationHash: "sha256:one",
67
+ });
68
+ const harness = await createPluginHarness([
69
+ ComputerRegistry,
70
+ SystemPromptRegistry,
71
+ ToolRegistry,
72
+ ]);
73
+ const provider: ComputerProvider = {
74
+ id: "fixture",
75
+ open: (identity, tenant, assignment) =>
76
+ Promise.resolve({
77
+ assignment,
78
+ identity,
79
+ tenant,
80
+ exec: {
81
+ execute: (request) => {
82
+ const command = (request.args ?? []).join(" ");
83
+ executedCommands.push(command);
84
+ const content = command.includes("archive --format=tar")
85
+ ? "source snapshot"
86
+ : "application artifact";
87
+ return Promise.resolve({
88
+ exitCode: 0,
89
+ stdout: new TextEncoder().encode(content),
90
+ stderr: new Uint8Array(),
91
+ outputTruncated: false,
92
+ });
93
+ },
94
+ },
95
+ close: () => Promise.resolve(),
96
+ }),
97
+ };
98
+ harness.root.computers.register(provider);
99
+ const fiber = await harness.mount(
100
+ createPackagePublisherAgentPlugin(
101
+ {
102
+ read: () => Promise.resolve(history),
103
+ publish: (command) => {
104
+ commands.push(command);
105
+ return Promise.resolve(active(command.commandId));
106
+ },
107
+ rollback: (command) => {
108
+ commands.push(command);
109
+ return Promise.resolve(active(command.commandId));
110
+ },
111
+ },
112
+ {
113
+ userId: "user-1",
114
+ defaultProviderId: "fixture",
115
+ },
116
+ ),
117
+ );
118
+
119
+ expect(
120
+ harness.root.tools.schemas({ turnType: "chat" }).map((tool) => tool.name),
121
+ ).toEqual(["list_setup_revisions", "publish_setup", "rollback_setup"]);
122
+ expect(
123
+ JSON.parse(
124
+ (await execute(harness.root.tools, "list_setup_revisions", {})).content,
125
+ ),
126
+ ).toEqual(history);
127
+ await execute(harness.root.tools, "publish_setup", {
128
+ checks: [{ name: "test", status: "passed" }],
129
+ });
130
+ await execute(harness.root.tools, "rollback_setup", {
131
+ packageRevision: 1,
132
+ });
133
+ expect(commands).toHaveLength(2);
134
+ expect(executedCommands[0]).toContain("git -C /home/box/setup init");
135
+ expect(executedCommands.slice(1)).toHaveLength(2);
136
+ expect(commands[0]).toMatchObject({
137
+ expectedRevision: 1,
138
+ candidate: {
139
+ source: "source snapshot",
140
+ applicationArtifact: "application artifact",
141
+ },
142
+ });
143
+ expect(commands[1]).toMatchObject({
144
+ expectedRevision: 1,
145
+ packageRevision: 1,
146
+ });
147
+
148
+ await fiber.dispose();
149
+ expect(harness.root.tools.schemas({ turnType: "chat" })).toEqual([]);
150
+ await harness.dispose();
151
+ });
152
+
153
+ test("satisfies built-in Package conventions", () => {
154
+ expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
155
+ name: "@frockbot/plugin-package-publisher",
156
+ contributionKinds: ["backend", "runtime", "client"],
157
+ });
158
+ });
159
+ });
package/src/agent.ts ADDED
@@ -0,0 +1,266 @@
1
+ import type { ToolDefinition } from "@frockbot/kernel-contracts";
2
+ import type { ComputerHandle } from "@frockbot/computer-core";
3
+ import type { Plugin } from "cordis";
4
+ import {
5
+ decodePublishPackageCommandV1,
6
+ decodeRollbackPackageCommandV1,
7
+ type PackageCandidateV1,
8
+ type PackagePublicationReceiptV1,
9
+ type PackageRevisionHistoryV1,
10
+ type PublishPackageCommandV1,
11
+ type RollbackPackageCommandV1,
12
+ } from "./shared.js";
13
+
14
+ export const SETUP_DIRECTORY = "/home/box/setup";
15
+ export const SETUP_APPLICATION_FILE = "dist/application.mjs";
16
+
17
+ export interface PackagePublisherAgentHost {
18
+ read(): Promise<PackageRevisionHistoryV1>;
19
+ publish(
20
+ command: PublishPackageCommandV1,
21
+ ): Promise<PackagePublicationReceiptV1>;
22
+ rollback(
23
+ command: RollbackPackageCommandV1,
24
+ ): Promise<PackagePublicationReceiptV1>;
25
+ }
26
+
27
+ export interface PackagePublisherAgentConfig {
28
+ userId: string;
29
+ defaultProviderId: string;
30
+ }
31
+
32
+ async function executeText(
33
+ computer: ComputerHandle,
34
+ command: string,
35
+ maxOutputBytes: number,
36
+ signal: AbortSignal,
37
+ ): Promise<string> {
38
+ if (!computer.exec) {
39
+ throw new Error("The selected Computer cannot run setup commands");
40
+ }
41
+ const result = await computer.exec.execute(
42
+ {
43
+ executable: "/bin/bash",
44
+ args: ["-lc", command],
45
+ timeoutMs: 120_000,
46
+ maxOutputBytes,
47
+ },
48
+ { signal },
49
+ );
50
+ if (result.exitCode !== 0) {
51
+ const stderr = new TextDecoder().decode(result.stderr).trim();
52
+ throw new Error(stderr || `setup command exited ${result.exitCode}`);
53
+ }
54
+ if (result.outputTruncated)
55
+ throw new Error("setup output exceeded its limit");
56
+ return new TextDecoder("utf-8", { fatal: true }).decode(result.stdout);
57
+ }
58
+
59
+ function publishCommand(
60
+ input: unknown,
61
+ commandId: string,
62
+ expectedRevision: number,
63
+ ): PublishPackageCommandV1 {
64
+ return decodePublishPackageCommandV1({
65
+ schemaVersion: 1,
66
+ commandId,
67
+ expectedRevision,
68
+ candidate: input,
69
+ });
70
+ }
71
+
72
+ function rollbackCommand(
73
+ input: unknown,
74
+ commandId: string,
75
+ expectedRevision: number,
76
+ ): RollbackPackageCommandV1 {
77
+ const value =
78
+ input && typeof input === "object" && !Array.isArray(input)
79
+ ? (input as Record<string, unknown>)
80
+ : {};
81
+ return decodeRollbackPackageCommandV1({
82
+ schemaVersion: 1,
83
+ commandId,
84
+ expectedRevision,
85
+ packageRevision: value.packageRevision,
86
+ });
87
+ }
88
+
89
+ export function createPackagePublisherAgentPlugin(
90
+ host: PackagePublisherAgentHost,
91
+ config: PackagePublisherAgentConfig,
92
+ ): Plugin.Function {
93
+ const userId = config.userId.trim();
94
+ const defaultProviderId = config.defaultProviderId.trim();
95
+ if (!userId) throw new Error("Package Publisher user id must be non-empty");
96
+ if (!defaultProviderId) {
97
+ throw new Error("Package Publisher provider id must be non-empty");
98
+ }
99
+ const plugin: Plugin.Function = (ctx) => {
100
+ const list: ToolDefinition = {
101
+ name: "list_setup_revisions",
102
+ // A general work tool: `executor` reach only.
103
+ admission: { subagentRoles: ["executor"] },
104
+ description:
105
+ "List the immutable setup revisions published for this User and identify the active revision.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {},
109
+ additionalProperties: false,
110
+ },
111
+ idempotent: true,
112
+ validate: (input) =>
113
+ Boolean(
114
+ input &&
115
+ typeof input === "object" &&
116
+ !Array.isArray(input) &&
117
+ Object.keys(input).length === 0,
118
+ ),
119
+ execute: async () => ({
120
+ content: JSON.stringify(await host.read()),
121
+ isError: false,
122
+ }),
123
+ };
124
+ const publish: ToolDefinition = {
125
+ name: "publish_setup",
126
+ // A general work tool: `executor` reach only.
127
+ admission: { subagentRoles: ["executor"] },
128
+ description: `Publish and activate the tested Git setup in ${SETUP_DIRECTORY}. The committed source is archived from HEAD, the built application is read from ${SETUP_APPLICATION_FILE}, and all required check results must be provided. This affects all Bots owned by the User.`,
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ checks: {
133
+ type: "array",
134
+ items: {
135
+ type: "object",
136
+ properties: {
137
+ name: { type: "string" },
138
+ status: { type: "string", enum: ["passed", "failed"] },
139
+ },
140
+ required: ["name", "status"],
141
+ additionalProperties: false,
142
+ },
143
+ },
144
+ },
145
+ required: ["checks"],
146
+ additionalProperties: false,
147
+ },
148
+ idempotent: false,
149
+ validate: (input) => {
150
+ try {
151
+ const value = input as { checks?: unknown };
152
+ publishCommand(
153
+ {
154
+ source: "pending",
155
+ applicationArtifact: "pending",
156
+ checks: value?.checks,
157
+ },
158
+ "validate",
159
+ 0,
160
+ );
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ },
166
+ execute: async (input, context) => {
167
+ // One Computer per User (ADR 0012): the assignment is keyed by the
168
+ // User, and the Bot attaches to it as a tenant.
169
+ const identity = { userId };
170
+ if (!ctx.computers.assignment(identity)) {
171
+ ctx.computers.assign(identity, defaultProviderId);
172
+ }
173
+ const computer = await ctx.computers.open(
174
+ identity,
175
+ { botId: context.botId },
176
+ { signal: context.signal },
177
+ );
178
+ let candidate: PackageCandidateV1;
179
+ try {
180
+ await executeText(
181
+ computer,
182
+ `mkdir -p ${SETUP_DIRECTORY} && (git -C ${SETUP_DIRECTORY} rev-parse --git-dir >/dev/null 2>&1 || git -C ${SETUP_DIRECTORY} init)`,
183
+ 10_000,
184
+ context.signal,
185
+ );
186
+ const [source, applicationArtifact] = await Promise.all([
187
+ executeText(
188
+ computer,
189
+ `git -C ${SETUP_DIRECTORY} archive --format=tar HEAD | base64 -w0`,
190
+ 5_000_000,
191
+ context.signal,
192
+ ),
193
+ executeText(
194
+ computer,
195
+ `cat ${SETUP_DIRECTORY}/${SETUP_APPLICATION_FILE}`,
196
+ 10_000_000,
197
+ context.signal,
198
+ ),
199
+ ]);
200
+ candidate = {
201
+ source,
202
+ applicationArtifact,
203
+ checks: (input as { checks: PackageCandidateV1["checks"] }).checks,
204
+ };
205
+ } finally {
206
+ await computer.close();
207
+ }
208
+ const current = await host.read();
209
+ const receipt = await host.publish(
210
+ publishCommand(candidate, crypto.randomUUID(), current.revision),
211
+ );
212
+ return {
213
+ content: JSON.stringify(receipt),
214
+ isError: receipt.status === "failed",
215
+ };
216
+ },
217
+ };
218
+ const rollback: ToolDefinition = {
219
+ name: "rollback_setup",
220
+ // A general work tool: `executor` reach only.
221
+ admission: { subagentRoles: ["executor"] },
222
+ description:
223
+ "Activate an earlier immutable setup revision for all Bots owned by the User.",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: { packageRevision: { type: "integer", minimum: 1 } },
227
+ required: ["packageRevision"],
228
+ additionalProperties: false,
229
+ },
230
+ idempotent: false,
231
+ validate: (input) => {
232
+ try {
233
+ rollbackCommand(input, "validate", 0);
234
+ return true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ },
239
+ execute: async (input) => {
240
+ const current = await host.read();
241
+ const receipt = await host.rollback(
242
+ rollbackCommand(input, crypto.randomUUID(), current.revision),
243
+ );
244
+ return { content: JSON.stringify(receipt), isError: false };
245
+ },
246
+ };
247
+ return [
248
+ ctx.tools.register(list),
249
+ ctx.tools.register(publish),
250
+ ctx.tools.register(rollback),
251
+ ctx.systemPrompt.register({
252
+ id: "package-publisher-workspace",
253
+ order: 90,
254
+ render: () =>
255
+ [
256
+ "## Editable setup",
257
+ `Edit and test the User's shared setup in ${SETUP_DIRECTORY} using the Computer.`,
258
+ `Publishing archives the current Git HEAD and reads ${SETUP_APPLICATION_FILE} from that fixed folder; file editing itself is not owned by the Package Publisher.`,
259
+ "Commit the intended source, run the setup's required checks, and call publish_setup only after they pass.",
260
+ ].join("\n"),
261
+ }),
262
+ ];
263
+ };
264
+ plugin.inject = ["computers", "tools", "systemPrompt"];
265
+ return plugin;
266
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createPackagePublisherBackendContribution,
4
+ type PackagePublisherGatewayHost,
5
+ } from "./backend.js";
6
+ import type {
7
+ PackagePublicationReceiptV1,
8
+ PackageRevisionHistoryV1,
9
+ } from "./shared.js";
10
+
11
+ const history: PackageRevisionHistoryV1 = {
12
+ schemaVersion: 1,
13
+ revision: 2,
14
+ activePackageRevision: 2,
15
+ revisions: [
16
+ {
17
+ packageRevision: 1,
18
+ applicationHash: "sha256:one",
19
+ publishedAt: "2026-09-01T00:00:00.000Z",
20
+ checks: [{ name: "test", status: "passed" }],
21
+ },
22
+ {
23
+ packageRevision: 2,
24
+ applicationHash: "sha256:two",
25
+ publishedAt: "2026-09-02T00:00:00.000Z",
26
+ checks: [{ name: "test", status: "passed" }],
27
+ },
28
+ ],
29
+ };
30
+
31
+ function receipt(commandId: string): PackagePublicationReceiptV1 {
32
+ return {
33
+ schemaVersion: 1,
34
+ commandId,
35
+ status: "active",
36
+ revision: 3,
37
+ packageRevision: 1,
38
+ applicationHash: "sha256:one",
39
+ };
40
+ }
41
+
42
+ describe("Package Publisher gateway contribution", () => {
43
+ test("exposes authenticated history and rollback without direct publication", async () => {
44
+ const calls: unknown[] = [];
45
+ const host: PackagePublisherGatewayHost = {
46
+ read: (userId) => {
47
+ calls.push(["read", userId]);
48
+ return Promise.resolve(history);
49
+ },
50
+ rollback: (userId, command) => {
51
+ calls.push(["rollback", userId, command]);
52
+ return Promise.resolve(receipt(command.commandId));
53
+ },
54
+ };
55
+ const contribution = createPackagePublisherBackendContribution(host);
56
+ const context = { userId: "user-1", client: "browser" as const };
57
+
58
+ const listed = await contribution.route(
59
+ new Request("https://bot.test/api/package-revisions"),
60
+ new URL("https://bot.test/api/package-revisions"),
61
+ context,
62
+ );
63
+ const published = await contribution.route(
64
+ new Request("https://bot.test/api/package-revisions", {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: JSON.stringify({
68
+ schemaVersion: 1,
69
+ commandId: "publish-1",
70
+ expectedRevision: 2,
71
+ candidate: {
72
+ source: "source",
73
+ applicationArtifact: "artifact",
74
+ checks: [{ name: "test", status: "passed" }],
75
+ },
76
+ }),
77
+ }),
78
+ new URL("https://bot.test/api/package-revisions"),
79
+ context,
80
+ );
81
+ const rolledBack = await contribution.route(
82
+ new Request("https://bot.test/api/package-revisions/rollback", {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({
86
+ schemaVersion: 1,
87
+ commandId: "rollback-1",
88
+ expectedRevision: 2,
89
+ packageRevision: 1,
90
+ }),
91
+ }),
92
+ new URL("https://bot.test/api/package-revisions/rollback"),
93
+ context,
94
+ );
95
+
96
+ expect(await listed?.json()).toEqual(history);
97
+ expect(published?.status).toBe(405);
98
+ expect(await rolledBack?.json()).toEqual(receipt("rollback-1"));
99
+ expect(calls.map((call) => (call as unknown[])[0])).toEqual([
100
+ "read",
101
+ "rollback",
102
+ ]);
103
+ });
104
+
105
+ test("rejects unauthenticated and malformed publication requests", async () => {
106
+ const unexpected = () => Promise.reject(new Error("unexpected host call"));
107
+ const contribution = createPackagePublisherBackendContribution({
108
+ read: unexpected,
109
+ rollback: unexpected,
110
+ });
111
+ const url = new URL("https://bot.test/api/package-revisions");
112
+
113
+ expect(
114
+ await contribution.route(new Request(url), url, {
115
+ client: "browser",
116
+ }),
117
+ ).toBeUndefined();
118
+ const response = await contribution.route(
119
+ new Request(url, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body: JSON.stringify({ schemaVersion: 1 }),
123
+ }),
124
+ url,
125
+ { userId: "user-1", client: "browser" },
126
+ );
127
+ expect(response?.status).toBe(405);
128
+ });
129
+ });