mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
The seam answered one question — what is behind this environment-variable name — and that shape cannot hold what an authorization grant is: a multi-field, rotating value keyed by a provider id rather than by a POSIX identifier. The Models page already works around the gap by inventing a synthetic environment name (`MINIMAX_CN_API_KEY`) for a route the user added by hand, because the store's key must look like one. `CredentialKey` is `<scope>/<id>`, where the scope is the owning plugin's registered name. The owner is in the key because a `grant` payload is written in its owner's format: two plugins serving the same provider name would otherwise read each other's payload, and a record left by an uninstalled plugin could not be told from a live one. The `/` also keeps the grammar disjoint from `CredentialRef`, so the key spaces cannot collide. `CredentialRecord` is `api-key` (key and/or provider environment values) or `grant` (an opaque, owner-owned payload). The asymmetry is deliberate: an api key is the harness's own data, a grant is a package it carries for someone else. `modifyRecord` is the only write path because a correct write depends on the current value — a token refresh is read-decide-replace under one cross-process lock, without which two processes rotating one refresh token lose whichever wrote first. `.credentials.yaml` becomes a versioned two-section document. The pre-release flat layout is refused by name, with the entry count and the one edit needed, rather than read as an empty store — which would surface as an authentication failure on the first request instead of at load. A grant payload is admitted in both directions, so a value the document could not read back exactly as written is refused rather than stored lossily.
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import { LocalCredentialProvider } from '../src/index.ts'
|
|
|
|
// The atomic write is the gated asynchronous hold point inside a queued
|
|
// write; gating it makes the dispose-versus-queued-write race fully
|
|
// deterministic. The lock helper passes through so the gated operation still
|
|
// runs inside its real acquire/release cycle.
|
|
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
|
|
let gate: Promise<void> = Promise.resolve()
|
|
return {
|
|
...actual,
|
|
writeFileAtomic: vi.fn(() => gate),
|
|
__setGate: (next: Promise<void>) => {
|
|
gate = next
|
|
},
|
|
}
|
|
})
|
|
|
|
async function setGate(next: Promise<void>): Promise<void> {
|
|
const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise<void>) => void }
|
|
mocked.__setGate(next)
|
|
}
|
|
|
|
const KEY = credentialRef('DSH_CRED_DRAIN_A')
|
|
const OTHER = credentialRef('DSH_CRED_DRAIN_B')
|
|
const RECORD = credentialKey('llm-drain', 'alpha')
|
|
const OTHER_RECORD = credentialKey('llm-drain', 'beta')
|
|
|
|
const cleanups: Array<() => Promise<void>> = []
|
|
|
|
afterEach(async () => {
|
|
await setGate(Promise.resolve())
|
|
while (cleanups.length > 0) await cleanups.pop()!()
|
|
})
|
|
|
|
describe('write-drain teardown', () => {
|
|
it('lets the in-flight write land and fails the queued one after disposal', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
|
const ctx = new Context()
|
|
const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
|
|
await fiber
|
|
const service = ctx.credentials
|
|
|
|
let release!: () => void
|
|
await setGate(new Promise<void>((resolveGate) => {
|
|
release = resolveGate
|
|
}))
|
|
const first = service.set(KEY, 'one')
|
|
// Let the first task pass its liveness checks and park on the gate, so it
|
|
// is genuinely in-flight when disposal begins.
|
|
await new Promise(resolvePause => setTimeout(resolvePause, 5))
|
|
// Attach the rejection handler up front: the queued write fails while the
|
|
// drain is still awaited, before any later `await expect` could run.
|
|
const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/)
|
|
const disposal = fiber.dispose()
|
|
// Give the drain disposer its first turn (set closed) before opening the gate.
|
|
await new Promise(resolvePause => setTimeout(resolvePause, 10))
|
|
release()
|
|
await disposal
|
|
|
|
await expect(first).resolves.toBeUndefined()
|
|
await secondRejects
|
|
expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
|
|
expect(await service.resolve(OTHER)).toBeUndefined()
|
|
})
|
|
|
|
it('fails a queued record write after disposal on the same terms', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-record-'))
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
|
const ctx = new Context()
|
|
const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
|
|
await fiber
|
|
const service = ctx.credentials
|
|
|
|
let release!: () => void
|
|
await setGate(new Promise<void>((resolveGate) => {
|
|
release = resolveGate
|
|
}))
|
|
const first = service.modifyRecord(RECORD, () => Promise.resolve({ kind: 'grant', payload: { v: 1 } }))
|
|
await new Promise(resolvePause => setTimeout(resolvePause, 5))
|
|
const queuedModify = expect(service.modifyRecord(OTHER_RECORD, () => Promise.resolve({ kind: 'api-key' })))
|
|
.rejects.toThrow(/disposed before the queued/)
|
|
const queuedDelete = expect(service.deleteRecord(OTHER_RECORD)).rejects.toThrow(/disposed before the queued/)
|
|
const disposal = fiber.dispose()
|
|
await new Promise(resolvePause => setTimeout(resolvePause, 10))
|
|
release()
|
|
await disposal
|
|
|
|
await expect(first).resolves.toEqual({ kind: 'grant', payload: { v: 1 } })
|
|
await queuedModify
|
|
await queuedDelete
|
|
expect(await service.readRecord(OTHER_RECORD)).toBeUndefined()
|
|
})
|
|
})
|