@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 +5 -0
- package/baked-marketplaces-reporter.ts +51 -0
- package/baked-marketplaces-reporter.unit.test.ts +77 -0
- package/base-image-contract.smoke.test.ts +22 -4
- package/boot.ts +15 -0
- package/package.json +3 -3
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.
|
|
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.
|
|
44
|
-
"@app-vitals/agent-lib": "1.
|
|
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",
|