Skip to content

Commit 8e03df1

Browse files
committed
fix(build): harden provider catalog packaging
1 parent a5f7c95 commit 8e03df1

11 files changed

Lines changed: 148 additions & 18 deletions

File tree

.github/workflows/desktop-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ jobs:
121121
working-directory: packages/desktop
122122
env:
123123
DEEPAGENT_CODE_CHANNEL: ${{ github.event_name == 'push' && 'prod' || (github.event.inputs.channel || 'prod') }}
124+
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
124125
NODE_OPTIONS: --max-old-space-size=4096
125126
run: bun run build
126127

.github/workflows/publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ jobs:
9494
env:
9595
DEEPAGENT_CODE_VERSION: ${{ needs.version.outputs.version }}
9696
DEEPAGENT_CODE_RELEASE: ${{ needs.version.outputs.release }}
97+
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
9798
GH_REPO: ${{ needs.version.outputs.repo }}
9899
GH_TOKEN: ${{ steps.committer.outputs.token }}
99100

@@ -326,6 +327,7 @@ jobs:
326327
working-directory: packages/desktop
327328
env:
328329
DEEPAGENT_CODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
330+
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
329331
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
330332
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
331333
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}

packages/deepagent-code/script/build-node.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ const result = await Bun.build({
2828
},
2929
})
3030
if (!result.success) throw new AggregateError(result.logs, "Failed to build the Node server")
31+
await Bun.file("./dist/node/models-dev.build.json").write(
32+
JSON.stringify(
33+
{
34+
source: generated.modelsSource,
35+
sha256: generated.modelsSha256,
36+
},
37+
null,
38+
2,
39+
),
40+
)
3141

3242
// Bun preserves CommonJS __dirname/__filename values for bundled dependencies. Those values point
3343
// at the build machine and are unusable after installation, so make the bundle reproducible and

packages/deepagent-code/script/build.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ for (const item of targets) {
253253
...(sourceCommit ? { sourceCommit } : {}),
254254
...(sourceDirty !== undefined ? { sourceDirty } : {}),
255255
binarySha256,
256+
modelsDev: {
257+
source: generated.modelsSource,
258+
sha256: generated.modelsSha256,
259+
},
256260
},
257261
},
258262
null,

packages/deepagent-code/script/generate.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,11 @@ process.chdir(path.resolve(import.meta.dir, ".."))
55

66
const models = await loadModelsData()
77
export const modelsData = models.data
8-
console.log(`Loaded models.dev snapshot from ${models.source}`)
8+
export const modelsSource = (() => {
9+
if (/^https?:\/\//.test(models.source)) return models.source
10+
const relative = path.relative(path.resolve(import.meta.dir, ".."), path.resolve(models.source))
11+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.replaceAll("\\", "/")
12+
return `external:${path.basename(models.source)}`
13+
})()
14+
export const modelsSha256 = models.sha256
15+
console.log(`Loaded models.dev snapshot from ${models.source} (sha256:${models.sha256})`)

packages/deepagent-code/script/models-data.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { mkdir, rename, rm } from "node:fs/promises"
2-
import os from "node:os"
32
import path from "node:path"
4-
import { resolveDataPath } from "@deepagent-code/core/global-path"
53

64
const repositorySnapshotFile = path.resolve(import.meta.dir, "../test/tool/fixtures/models-api.json")
75

@@ -18,7 +16,7 @@ export async function loadModelsData(
1816
if (configuredFile) {
1917
const configured = await readCatalog(configuredFile)
2018
if (!configured) throw new Error(`Configured models.dev snapshot is invalid: ${configuredFile}`)
21-
return { data: JSON.stringify(configured), source: configuredFile }
19+
return result(configured, configuredFile)
2220
}
2321

2422
const modelsURL = (environment.DEEPAGENT_CODE_MODELS_URL?.trim() || "https://models.dev").replace(/\/$/, "")
@@ -27,26 +25,34 @@ export async function loadModelsData(
2725
})
2826
.then(async (response) => (response.ok ? catalog(await response.json()) : undefined))
2927
.catch(() => undefined)
30-
const cacheFile = options.cacheFile ?? path.join(resolveDataPath(), "cache", "models.json")
3128
if (remote) {
32-
await persistCatalog(cacheFile, remote).catch((error) =>
33-
console.warn(
34-
`Unable to update models.dev build cache: ${error instanceof Error ? error.message : String(error)}`,
35-
),
36-
)
37-
return { data: JSON.stringify(remote), source: `${modelsURL}/api.json` }
29+
if (options.cacheFile) {
30+
await persistCatalog(options.cacheFile, remote).catch((error) =>
31+
console.warn(
32+
`Unable to update models.dev build cache: ${error instanceof Error ? error.message : String(error)}`,
33+
),
34+
)
35+
}
36+
return result(remote, `${modelsURL}/api.json`)
3837
}
3938

40-
const fallbacks = options.fallbackFiles ?? [
41-
cacheFile,
42-
path.join(os.homedir(), ".cache", "opencode", "models.json"),
43-
repositorySnapshotFile,
44-
]
39+
// Build inputs must not depend on the builder's DeepAgent/OpenCode runtime caches. A caller may
40+
// pass explicit fallback files for tests or controlled builds; the default is the committed copy.
41+
const fallbacks = options.fallbackFiles ?? [repositorySnapshotFile]
4542
const cached = (await Promise.all(fallbacks.map(async (file) => ({ file, data: await readCatalog(file) })))).find(
4643
(item): item is { file: string; data: Record<string, unknown> } => item.data !== undefined,
4744
)
4845
if (!cached) throw new Error(`Unable to load a valid models.dev catalog from ${modelsURL} or local snapshots`)
49-
return { data: JSON.stringify(cached.data), source: cached.file }
46+
return result(cached.data, cached.file)
47+
}
48+
49+
function result(data: Record<string, unknown>, source: string) {
50+
const serialized = JSON.stringify(data)
51+
return {
52+
data: serialized,
53+
source,
54+
sha256: new Bun.CryptoHasher("sha256").update(serialized).digest("hex"),
55+
}
5056
}
5157

5258
function catalog(value: unknown): Record<string, unknown> | undefined {

packages/deepagent-code/test/script/models-data.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe("models.dev build data", () => {
3535

3636
expect(result.source).toBe(file)
3737
expect(JSON.parse(result.data)).toEqual(catalog)
38+
expect(result.sha256).toBe(new Bun.CryptoHasher("sha256").update(JSON.stringify(catalog)).digest("hex"))
3839
})
3940

4041
test("fetches a fresh catalog and persists the last good copy", async () => {
@@ -79,4 +80,27 @@ describe("models.dev build data", () => {
7980
"Configured models.dev snapshot is invalid",
8081
)
8182
})
83+
84+
test("does not read or rewrite a builder-local cache when the network is unavailable", async () => {
85+
await using directory = await fixture()
86+
const cacheFile = path.join(directory.root, "models.json")
87+
const builderOnly = {
88+
"builder-only": {
89+
id: "builder-only",
90+
name: "Builder only",
91+
models: { leaked: { id: "leaked" } },
92+
},
93+
}
94+
await Bun.write(cacheFile, JSON.stringify(builderOnly))
95+
96+
const result = await loadModelsData({
97+
environment: { DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1" },
98+
cacheFile,
99+
requestTimeoutMs: 200,
100+
})
101+
102+
expect(result.source).toBe(path.resolve(import.meta.dir, "../tool/fixtures/models-api.json"))
103+
expect(JSON.parse(result.data)["builder-only"]).toBeUndefined()
104+
expect(await Bun.file(cacheFile).json()).toEqual(builderOnly)
105+
})
82106
})

packages/deepagent-code/test/server/httpapi-provider.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,37 @@ function setEnvScoped(key: string, value: string) {
263263
}
264264

265265
describe("provider HttpApi", () => {
266+
it.instance(
267+
"only autoloads the public hosted provider for a blank installation",
268+
Effect.gen(function* () {
269+
const directory = (yield* TestInstance).directory
270+
yield* setEnvScoped("DEEPAGENT_CODE_AUTH_CONTENT", "{}")
271+
272+
const response = yield* request("/provider", {
273+
headers: { "x-deepagent-code-directory": directory },
274+
})
275+
expect(response.status).toBe(200)
276+
277+
const body = yield* response.json
278+
expect(providerList(body, "all").length).toBeGreaterThan(0)
279+
expect(isRecord(body) && body.connected).toEqual(["deepagent-code"])
280+
expect(isRecord(body) && isRecord(body.default) && Object.keys(body.default).length).toBeGreaterThan(0)
281+
282+
const hosted = providerByID(body, "all", "deepagent-code")
283+
expect(hosted).toBeDefined()
284+
expect(isRecord(hosted) && isRecord(hosted.models) && Object.keys(hosted.models).length).toBeGreaterThan(0)
285+
expect(
286+
isRecord(hosted) &&
287+
isRecord(hosted.models) &&
288+
Object.values(hosted.models).every(
289+
(model) => isRecord(model) && isRecord(model.cost) && model.cost.input === 0,
290+
),
291+
).toBe(true)
292+
}),
293+
projectOptions,
294+
30000,
295+
)
296+
266297
it.instance.skip(
267298
"returns public v2 provider not found errors",
268299
Effect.gen(function* () {

packages/desktop/electron.vite.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ export default defineConfig(({ command }) => ({
5656
async writeBundle() {
5757
await mkdir("./out/main/chunks", { recursive: true })
5858
for (const file of await readdir(DEEPAGENT_CODE_SERVER_DIST)) {
59-
if (!file.endsWith(".wasm") && (command !== "build" || (file !== "node.js" && file !== "node.js.map")))
59+
if (
60+
!file.endsWith(".wasm") &&
61+
(command !== "build" || !["node.js", "node.js.map", "models-dev.build.json"].includes(file))
62+
)
6063
continue
6164
await copyFile(`${DEEPAGENT_CODE_SERVER_DIST}/${file}`, `./out/main/chunks/${file}`)
6265
}

packages/desktop/scripts/audit-package.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ async function fixture() {
1818
describe("auditPackageInputs", () => {
1919
test("accepts generated application assets", async () => {
2020
await using root = await fixture()
21+
await mkdir(path.join(root.root, "out", "main", "chunks"), { recursive: true })
22+
await writeFile(
23+
path.join(root.root, "out", "main", "chunks", "models-dev.build.json"),
24+
JSON.stringify({ source: "test/tool/fixtures/models-api.json", sha256: "a".repeat(64) }),
25+
)
2126
await expect(auditPackageInputs(root.root)).resolves.toBeUndefined()
2227
})
2328

@@ -44,4 +49,27 @@ describe("auditPackageInputs", () => {
4449
await symlink(path.join(root.root, "out", "main", "index.js"), path.join(root.root, "resources", "linked.js"))
4550
await expect(auditPackageInputs(root.root)).rejects.toThrow("symbolic link")
4651
})
52+
53+
for (const runtimeFile of [
54+
"config.json",
55+
"config.jsonc",
56+
"deepagent-code.json",
57+
"deepagent-code.jsonc",
58+
"provider-template.jsonc",
59+
"mcp-auth.json",
60+
"mcp-secrets.json",
61+
"models.json",
62+
"model.json",
63+
"plugin-meta.json",
64+
"sessions.json",
65+
"artifact-key.json",
66+
"token-keyring.json",
67+
"password",
68+
]) {
69+
test(`rejects runtime configuration or state file ${runtimeFile}`, async () => {
70+
await using root = await fixture()
71+
await writeFile(path.join(root.root, "resources", runtimeFile), "{}")
72+
await expect(auditPackageInputs(root.root)).rejects.toThrow("runtime user-data file")
73+
})
74+
}
4775
})

0 commit comments

Comments
 (0)