@app-vitals/agent-runtime 1.6.0 → 1.8.0

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/Dockerfile CHANGED
@@ -216,6 +216,8 @@ COPY agent-types/ ./agent-types/
216
216
  # Composition root spawned by runtime/entrypoint.ts (../index.ts).
217
217
  COPY index.ts ./index.ts
218
218
  COPY scripts/ ./scripts/
219
+ # Baked marketplace (plugins/.claude-plugin/marketplace.json + plugins/<name>/).
220
+ COPY plugins/ ./plugins/
219
221
 
220
222
  # Module-resolution guard: bundle every runtime source file as its own entry
221
223
  # point so an unresolvable import fails the build itself, rather than only
@@ -259,6 +261,9 @@ COPY --from=build --chown=1000:1000 /app/agent-types ./agent-types
259
261
  COPY --from=build --chown=1000:1000 /app/index.ts ./index.ts
260
262
  COPY --from=build --chown=1000:1000 /app/scripts ./scripts
261
263
  COPY --from=build --chown=1000:1000 /app/package.json ./package.json
264
+ # Baked marketplace: installPlugins() (runtime/setup.ts) registers every
265
+ # /opt/shipwright/marketplaces/<name>/ that has .claude-plugin/marketplace.json.
266
+ COPY --from=build --chown=1000:1000 /app/plugins /opt/shipwright/marketplaces/app-vitals-agent/
262
267
 
263
268
  RUN chmod +x /app/scripts/bin/git-credential-shipwright.sh \
264
269
  && chmod +x /app/scripts/bin/gh \
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Baked marketplace reporter — at boot, lists the marketplace directories
3
+ * baked into the image (BAKED_MARKETPLACES_ROOT) and reports their names to
4
+ * admin: PUT /agents/:agentId/baked-marketplaces (overwrite). Read-only
5
+ * visibility: admin stores the list so the UI can show what the image ships.
6
+ *
7
+ * Fire-and-forget: never throws; non-2xx and thrown errors are swallowed with
8
+ * console.warn. The payload is the directory basenames only.
9
+ */
10
+
11
+ import { basename } from "node:path";
12
+ import { BAKED_MARKETPLACES_ROOT, discoverBakedMarketplaces } from "./setup.ts";
13
+
14
+ const REQUEST_TIMEOUT_MS = 10_000;
15
+
16
+ interface ReportBakedMarketplacesOptions {
17
+ apiUrl: string;
18
+ agentId: string;
19
+ apiKey: string;
20
+ root?: string;
21
+ fetchFn?: typeof fetch;
22
+ }
23
+
24
+ export async function reportBakedMarketplaces(
25
+ opts: ReportBakedMarketplacesOptions,
26
+ ): Promise<void> {
27
+ const url = `${opts.apiUrl}/agents/${opts.agentId}/baked-marketplaces`;
28
+ try {
29
+ const names = discoverBakedMarketplaces(
30
+ opts.root ?? BAKED_MARKETPLACES_ROOT,
31
+ ).map((dir) => basename(dir));
32
+ const res = await (opts.fetchFn ?? fetch)(url, {
33
+ method: "PUT",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ Authorization: `Bearer ${opts.apiKey}`,
37
+ },
38
+ body: JSON.stringify({ names }),
39
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
40
+ });
41
+ if (!res.ok) {
42
+ console.warn(
43
+ `[baked-marketplaces-reporter] PUT ${url} returned ${res.status} — swallowing`,
44
+ );
45
+ }
46
+ } catch (err) {
47
+ console.warn(
48
+ `[baked-marketplaces-reporter] PUT ${url} failed: ${String(err)} — swallowing`,
49
+ );
50
+ }
51
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Unit tests for reportBakedMarketplaces: reads the baked marketplace dirs,
3
+ * PUTs the names once, and never throws.
4
+ */
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { reportBakedMarketplaces } from "./baked-marketplaces-reporter.ts";
11
+
12
+ const roots: string[] = [];
13
+ afterEach(() => {
14
+ for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
15
+ });
16
+
17
+ function bakedRoot(names: string[]): string {
18
+ const root = mkdtempSync(join(tmpdir(), "baked-"));
19
+ roots.push(root);
20
+ for (const n of names) {
21
+ mkdirSync(join(root, n, ".claude-plugin"), { recursive: true });
22
+ writeFileSync(join(root, n, ".claude-plugin", "marketplace.json"), "{}");
23
+ }
24
+ return root;
25
+ }
26
+
27
+ function harness(response: Response | Error = new Response("{}")) {
28
+ const calls: { url: string; init: RequestInit | undefined }[] = [];
29
+ const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
30
+ calls.push({ url: String(url), init });
31
+ if (response instanceof Error) throw response;
32
+ return response;
33
+ }) as typeof fetch;
34
+ return { calls, fetchFn };
35
+ }
36
+
37
+ const base = { apiUrl: "http://admin", agentId: "a1", apiKey: "k" };
38
+
39
+ describe("reportBakedMarketplaces", () => {
40
+ test("PUTs the basenames once with the agent key", async () => {
41
+ const root = bakedRoot(["vitals-os", "shipwright"]);
42
+ const { calls, fetchFn } = harness();
43
+ await reportBakedMarketplaces({ ...base, root, fetchFn });
44
+ expect(calls).toHaveLength(1);
45
+ expect(calls[0].url).toBe("http://admin/agents/a1/baked-marketplaces");
46
+ expect(calls[0].init?.method).toBe("PUT");
47
+ expect(new Headers(calls[0].init?.headers).get("authorization")).toBe(
48
+ "Bearer k",
49
+ );
50
+ const body = JSON.parse(calls[0].init?.body as string);
51
+ expect([...body.names].sort()).toEqual(["shipwright", "vitals-os"]);
52
+ });
53
+
54
+ test("reports an empty list when the root is missing", async () => {
55
+ const { calls, fetchFn } = harness();
56
+ await reportBakedMarketplaces({
57
+ ...base,
58
+ root: "/nonexistent-baked-root",
59
+ fetchFn,
60
+ });
61
+ expect(JSON.parse(calls[0].init?.body as string)).toEqual({ names: [] });
62
+ });
63
+
64
+ test("swallows a non-2xx response", async () => {
65
+ const { fetchFn } = harness(new Response("no", { status: 500 }));
66
+ await expect(
67
+ reportBakedMarketplaces({ ...base, root: bakedRoot(["x"]), fetchFn }),
68
+ ).resolves.toBeUndefined();
69
+ });
70
+
71
+ test("swallows a thrown fetch error", async () => {
72
+ const { fetchFn } = harness(new Error("boom"));
73
+ await expect(
74
+ reportBakedMarketplaces({ ...base, root: bakedRoot(["x"]), fetchFn }),
75
+ ).resolves.toBeUndefined();
76
+ });
77
+ });
@@ -113,6 +113,27 @@ describe.skipIf(!BASE_IMAGE)("agent base-image contract", () => {
113
113
  expect(adds).toContain(`${CONVENTION_ROOT}/${MARKETPLACE_NAME}`);
114
114
  }, 60_000);
115
115
 
116
+ test("image bakes the app-vitals-agent marketplace and agent-platform plugin manifests", () => {
117
+ const base = `${CONVENTION_ROOT}/app-vitals-agent`;
118
+ const run = docker([
119
+ "run",
120
+ "--rm",
121
+ "--entrypoint",
122
+ "bash",
123
+ DERIVED_TAG,
124
+ "-c",
125
+ `cat ${base}/.claude-plugin/marketplace.json && echo ::SPLIT:: && cat ${base}/agent-platform/.claude-plugin/plugin.json && test -f ${base}/agent-platform/scripts/slack-say.ts && test -f ${base}/agent-platform/skills/slack-say/SKILL.md`,
126
+ ]);
127
+ expect(run.exitCode, run.stderr).toBe(0);
128
+ const [marketplaceJson, pluginJson] = run.stdout.split("::SPLIT::");
129
+ const marketplace = JSON.parse(marketplaceJson as string);
130
+ const plugin = JSON.parse(pluginJson as string);
131
+ expect(marketplace.name).toBe("app-vitals-agent");
132
+ expect(marketplace.plugins.map((p: { name: string }) => p.name)).toContain(
133
+ plugin.name,
134
+ );
135
+ }, 60_000);
136
+
116
137
  test("entrypoint-main.ts exists and runs under `bun run`", () => {
117
138
  // No agent id/url/key: runEntrypoint() reports the missing vars and exits 1.
118
139
  // A missing file or broken import graph fails differently ("Module not
@@ -170,10 +191,7 @@ describe.skipIf(!BASE_IMAGE)("agent base-image contract", () => {
170
191
  // Static guard (no Docker needed): the runtime stage must take node_modules
171
192
  // from the production-only stage, never the build stage's full install.
172
193
  describe("runtime Dockerfile", () => {
173
- const dockerfile = readFileSync(
174
- join(import.meta.dir, "Dockerfile"),
175
- "utf8",
176
- );
194
+ const dockerfile = readFileSync(join(import.meta.dir, "Dockerfile"), "utf8");
177
195
 
178
196
  test("copies node_modules from the production-only stage", () => {
179
197
  expect(dockerfile).toMatch(
package/boot.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  } from "./agent-slack-membership-ref.ts";
40
40
  import { agentTrialExpiryRef } from "./agent-trial-expiry-ref.ts";
41
41
  import { applyEnvToProcess } from "./anthropic-federation-guard.ts";
42
+ import { reportBakedMarketplaces } from "./baked-marketplaces-reporter.ts";
42
43
  import { createChatPoller } from "./chat-poller.ts";
43
44
  import {
44
45
  HttpChatTokenReporter,
@@ -259,6 +260,20 @@ export async function startAgent(
259
260
  })
260
261
  : new NoopGitHubInstallationsReporter();
261
262
 
263
+ // PMK-1.4: report the image's baked marketplace names to admin once at boot
264
+ // (overwrite, read-only visibility). Never throws; skipped when not wired.
265
+ if (
266
+ config.shipwright.apiUrl &&
267
+ config.shipwright.apiKey &&
268
+ config.shipwright.agentId
269
+ ) {
270
+ void reportBakedMarketplaces({
271
+ apiUrl: config.shipwright.apiUrl,
272
+ agentId: config.shipwright.agentId,
273
+ apiKey: config.shipwright.apiKey,
274
+ });
275
+ }
276
+
262
277
  // Always-loaded-context stamp (context-stamp.ts): fingerprints the workspace
263
278
  // CLAUDE.md + its imports + no-`paths` rules + the plugin version, read once
264
279
  // per run so cron-run rows can be grouped by what the model was given. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -40,8 +40,8 @@
40
40
  "./workspace-paths": "./workspace-paths.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@app-vitals/agent-admin": "1.6.0",
44
- "@app-vitals/agent-lib": "1.6.0",
43
+ "@app-vitals/agent-admin": "1.8.0",
44
+ "@app-vitals/agent-lib": "1.8.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",