Skip to content

Commit 2944348

Browse files
committed
Merge branch 'feat/dcg-binary-service' into feat/dcg-setting
2 parents ca0f252 + 5f3854a commit 2944348

12 files changed

Lines changed: 316 additions & 50 deletions

File tree

src/services/code-index/semble/__tests__/semble-downloader.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ describe("semble-downloader", () => {
925925
// excluded by the currentArchivePath guard). It is unlinked only by
926926
// the pre-download partial-archive cleanup and the post-install
927927
// archive cleanup steps. unrelated.txt is never touched.
928-
expect(fs.unlink).not.toHaveBeenCalledWith(path.join("/storage", "unrelated.txt"))
928+
expect(fs.rm).not.toHaveBeenCalledWith(path.join("/storage", "unrelated.txt"), expect.anything())
929929
// Sanity: the current archive path is never passed to the stale sweep.
930930
// It is unlinked exactly twice (pre-download cleanup + post-install
931931
// archive cleanup), never via cleanupStaleArchives.

src/services/code-index/semble/semble-downloader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const SEMBLE_ARCHIVES: Record<string, { archive: string; binary: string }> = {
2626
export const SEMBLE_VERSION = "v0.4.1"
2727
const DOWNLOAD_BASE_URL = `https://github.com/Zoo-Code-Org/sembleexec/releases/download/${SEMBLE_VERSION}`
2828
const VERSION_FILE = ".semble-version"
29+
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
2930

3031
/**
3132
* SHA-256 checksums for each platform archive at SEMBLE_VERSION.
@@ -121,13 +122,16 @@ export async function downloadSemble(storageDir: string): Promise<string | undef
121122
name: "Semble",
122123
trustedDomains: TRUSTED_DOWNLOAD_DOMAINS,
123124
timeoutMs: 120_000,
125+
maxBytes: MAX_ARCHIVE_BYTES,
124126
}),
125127
verifyArchive: (archivePath) => verifyChecksum(archivePath, expectedChecksum),
126128
extractArchive: async (archivePath, stagingDir) => {
127129
if (info.archive.endsWith(".tar.gz")) {
128130
await extractTarGzArchive(archivePath, stagingDir)
129131
} else if (info.archive.endsWith(".zip")) {
130132
await extractZipArchive(archivePath, stagingDir)
133+
} else {
134+
throw new Error(`Unsupported semble archive format: ${info.archive}`)
131135
}
132136
},
133137
})

src/services/destructive-command-guard/__tests__/manager.spec.ts

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe("Destructive Command Guard manager", () => {
6363
expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true)
6464
expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false)
6565
expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false)
66+
expect(isTrustedDownloadUrl("https://github.com.evil.com/release")).toBe(false)
6667
expect(isTrustedDownloadUrl("not a URL")).toBe(false)
6768
})
6869

@@ -72,8 +73,70 @@ describe("Destructive Command Guard manager", () => {
7273
)
7374
})
7475

76+
it("rejects non-successful HTTP responses", async () => {
77+
const response = Object.assign(new PassThrough(), { statusCode: 503, headers: {}, destroy: vi.fn() })
78+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
79+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
80+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
81+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
82+
return request as unknown as ReturnType<typeof get>
83+
})
84+
85+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
86+
"DCG download failed with HTTP 503",
87+
)
88+
})
89+
90+
it("rejects request errors", async () => {
91+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
92+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
93+
94+
const download = downloadFile("https://github.com/release", path.join(tempDir, "archive"))
95+
request.emit("error", new Error("socket failed"))
96+
97+
await expect(download).rejects.toThrow("socket failed")
98+
})
99+
100+
it("times out stalled requests", async () => {
101+
const request = Object.assign(new EventEmitter(), {
102+
setTimeout: vi.fn((_timeout: number, callback: () => void) => setImmediate(callback)),
103+
destroy: vi.fn((error: Error) => request.emit("error", error)),
104+
})
105+
mockGet.mockReturnValue(request as unknown as ReturnType<typeof get>)
106+
107+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
108+
"DCG download timed out",
109+
)
110+
expect(request.setTimeout).toHaveBeenCalledWith(120_000, expect.any(Function))
111+
})
112+
113+
it("rejects archives larger than 50 MiB", async () => {
114+
const response = Object.assign(new PassThrough(), {
115+
statusCode: 200,
116+
headers: { "content-length": String(50 * 1024 * 1024 + 1) },
117+
destroy: vi.fn(),
118+
})
119+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
120+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
121+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
122+
setImmediate(() => callback?.(response as unknown as IncomingMessage))
123+
return request as unknown as ReturnType<typeof get>
124+
})
125+
126+
await expect(downloadFile("https://github.com/release", path.join(tempDir, "archive"))).rejects.toThrow(
127+
"DCG archive exceeds the download size limit",
128+
)
129+
})
130+
75131
it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => {
76132
expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset")
133+
expect(
134+
resolveTrustedRedirect(
135+
"https://github.com/release",
136+
"https://release-assets.githubusercontent.com/asset",
137+
5,
138+
),
139+
).toBe("https://release-assets.githubusercontent.com/asset")
77140
expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow(
78141
"DCG download redirected to an untrusted host",
79142
)
@@ -92,9 +155,7 @@ describe("Destructive Command Guard manager", () => {
92155
const checksum = createHash("sha256").update(contents).digest("hex")
93156

94157
await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined()
95-
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(
96-
"DCG archive checksum verification failed",
97-
)
158+
await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow(`got ${checksum}`)
98159
})
99160

100161
it("uses the platform ZIP extractor", async () => {
@@ -180,6 +241,22 @@ describe("Destructive Command Guard manager", () => {
180241
}
181242
})
182243

244+
it("warns when the current platform is unsupported", async () => {
245+
const platformKey = `${process.platform}-${process.arch}`
246+
const info = DCG_ARCHIVES[platformKey]
247+
if (!info) return
248+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
249+
Reflect.deleteProperty(DCG_ARCHIVES, platformKey)
250+
251+
try {
252+
await expect(ensureDcgInstalled(tempDir)).resolves.toBeUndefined()
253+
expect(warnSpy).toHaveBeenCalledWith(`[DCG] Unsupported platform: ${platformKey}`)
254+
} finally {
255+
Reflect.set(DCG_ARCHIVES, platformKey, info)
256+
warnSpy.mockRestore()
257+
}
258+
})
259+
183260
it("downloads, verifies, extracts, and deduplicates a new installation", async () => {
184261
const info = getDcgArchiveInfo()
185262
expect(info).toBeDefined()
@@ -251,4 +328,60 @@ describe("Destructive Command Guard manager", () => {
251328
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
252329
}
253330
})
331+
332+
it.skipIf(!getDcgArchiveInfo()?.archive.endsWith(".zip"))(
333+
"downloads, verifies, extracts, and installs a ZIP archive",
334+
async () => {
335+
const info = getDcgArchiveInfo()
336+
if (!info) throw new Error("Expected a ZIP archive in this test")
337+
338+
const archive = Buffer.from("test ZIP archive")
339+
const originalChecksum = info.sha256
340+
Object.defineProperty(info, "sha256", {
341+
value: createHash("sha256").update(archive).digest("hex"),
342+
configurable: true,
343+
})
344+
const response = Object.assign(new PassThrough(), {
345+
statusCode: 200,
346+
headers: { "content-length": String(archive.length) },
347+
})
348+
const request = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() })
349+
mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => {
350+
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback
351+
setImmediate(() => {
352+
callback?.(response as unknown as IncomingMessage)
353+
response.end(archive)
354+
})
355+
return request as unknown as ReturnType<typeof get>
356+
})
357+
mockSpawn.mockImplementation((_executable, args) => {
358+
const child = Object.assign(new EventEmitter(), {
359+
stdout: new PassThrough(),
360+
stderr: new PassThrough(),
361+
kill: vi.fn(),
362+
})
363+
setImmediate(async () => {
364+
const destinationIndex = args.indexOf(
365+
"$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force",
366+
)
367+
const stagingDir = args[destinationIndex + 2]
368+
await writeFile(path.join(stagingDir, info.binary), "ZIP executable")
369+
child.emit("close", 0)
370+
})
371+
return child as unknown as ReturnType<typeof spawn>
372+
})
373+
374+
try {
375+
const binaryPath = await ensureDcgInstalled(tempDir)
376+
if (!binaryPath) throw new Error("Expected DCG to be supported in this test")
377+
expect(await readFile(binaryPath, "utf8")).toBe("ZIP executable")
378+
expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe(
379+
DCG_VERSION,
380+
)
381+
await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow()
382+
} finally {
383+
Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true })
384+
}
385+
},
386+
)
254387
})

src/services/destructive-command-guard/__tests__/runner.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@ function emitResult(child: MockChild, payload: unknown, code: number): void {
3535
}
3636

3737
describe("runDcg", () => {
38+
let warnSpy: ReturnType<typeof vi.spyOn>
39+
3840
beforeEach(() => {
3941
vi.useRealTimers()
4042
mockSpawn.mockReset()
43+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
4144
})
4245

43-
afterEach(() => vi.useRealTimers())
46+
afterEach(() => {
47+
vi.useRealTimers()
48+
warnSpy.mockRestore()
49+
})
4450

4551
it.each([
4652
[{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }],
@@ -67,6 +73,34 @@ describe("runDcg", () => {
6773
expect.any(Array),
6874
expect.objectContaining({ cwd: "/workspace" }),
6975
)
76+
if (expected.decision === "deny") {
77+
const reason = "reason" in expected ? expected.reason : undefined
78+
expect(warnSpy).toHaveBeenCalledWith("[DCG] Command denied", reason ?? "No reason provided")
79+
}
80+
})
81+
82+
it("passes only the environment variables DCG requires", async () => {
83+
const child = createChild()
84+
useChild(child)
85+
const originalToken = process.env.GITHUB_TOKEN
86+
const originalTmpdir = process.env.TMPDIR
87+
process.env.GITHUB_TOKEN = "secret"
88+
process.env.TMPDIR = "/sandbox/tmp"
89+
90+
try {
91+
const result = runDcg("/dcg", "echo test", "/workspace")
92+
emitResult(child, { schema_version: 1, decision: "allow" }, 0)
93+
await result
94+
95+
const options = mockSpawn.mock.calls[0][2]
96+
expect(options?.env).toMatchObject({ NO_COLOR: "1", TMPDIR: "/sandbox/tmp" })
97+
expect(options?.env).not.toHaveProperty("GITHUB_TOKEN")
98+
} finally {
99+
if (originalToken === undefined) delete process.env.GITHUB_TOKEN
100+
else process.env.GITHUB_TOKEN = originalToken
101+
if (originalTmpdir === undefined) delete process.env.TMPDIR
102+
else process.env.TMPDIR = originalTmpdir
103+
}
70104
})
71105

72106
it.each([
@@ -82,6 +116,7 @@ describe("runDcg", () => {
82116
child.emit("close", code, null)
83117

84118
await expect(result).rejects.toThrow(message)
119+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", message)
85120
})
86121

87122
it("rejects non-DCG exit statuses with stderr", async () => {
@@ -93,6 +128,7 @@ describe("runDcg", () => {
93128
child.emit("close", 2, null)
94129

95130
await expect(result).rejects.toThrow("DCG evaluation failed: failure details")
131+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", "DCG evaluation failed: failure details")
96132
})
97133

98134
it("rejects process startup errors", async () => {
@@ -103,6 +139,7 @@ describe("runDcg", () => {
103139
child.emit("error", new Error("ENOENT"))
104140

105141
await expect(result).rejects.toThrow("Unable to start DCG: ENOENT")
142+
expect(warnSpy).toHaveBeenCalledWith("[DCG]", "Unable to start DCG: ENOENT")
106143
})
107144

108145
it("rejects excessive output and kills the process", async () => {

src/services/destructive-command-guard/manager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
resolveTrustedRedirect as resolveManagedBinaryRedirect,
88
verifySha256Checksum,
99
} from "../managed-binary/download"
10-
import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../managed-binary/install"
10+
import { ensureManagedBinaryInstalled } from "../managed-binary/install"
1111

1212
import {
1313
DCG_ARCHIVES,
@@ -18,6 +18,7 @@ import {
1818
} from "./constants"
1919

2020
const VERSION_FILE = ".dcg-version"
21+
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
2122

2223
export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined {
2324
return DCG_ARCHIVES[`${platform}-${arch}`]
@@ -53,11 +54,16 @@ export function downloadFile(url: string, destination: string, maxRedirects = 5)
5354
trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS,
5455
timeoutMs: 120_000,
5556
maxRedirects,
57+
maxBytes: MAX_ARCHIVE_BYTES,
5658
})
5759
}
5860

5961
export async function verifyChecksum(filePath: string, expected: string): Promise<void> {
60-
await verifySha256Checksum(filePath, expected, () => new Error("DCG archive checksum verification failed"))
62+
await verifySha256Checksum(
63+
filePath,
64+
expected,
65+
(actual) => new Error(`DCG archive checksum verification failed (got ${actual})`),
66+
)
6167
}
6268

6369
export async function extractSingleBinary(
@@ -76,6 +82,7 @@ export async function extractSingleBinary(
7682
function installDcg(storageDir: string): Promise<string | undefined> {
7783
const info = getDcgArchiveInfo()
7884
if (!info) {
85+
console.warn(`[DCG] Unsupported platform: ${process.platform}-${process.arch}`)
7986
return Promise.resolve(undefined)
8087
}
8188

0 commit comments

Comments
 (0)