mirror of
https://github.com/joelhooks/joelclaw.git
synced 2026-09-19 01:24:04 +08:00
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { createHash } from "node:crypto"
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
|
|
import { parseMinimalToml } from "../packages/cli/src/capabilities/config"
|
|
import { resolveFlowingRecallPortConfig } from "../packages/cli/src/recall/flowing-port"
|
|
import { PINNED_READ_ARTIFACT_SHA256 } from "../packages/cli/src/recall/release-manifest"
|
|
import { testRelease } from "../packages/cli/src/recall/test-fixtures"
|
|
import {
|
|
FLOWING_MEMORY_RUNTIME_DATABASE_SECRET_NAME,
|
|
runRecallCutover,
|
|
updateRecallConfig,
|
|
} from "./recall-cutover"
|
|
|
|
const SECRET_SENTINEL = "must-never-leave-agent-secrets"
|
|
|
|
function candidateSource(): string {
|
|
return `#!${process.execPath}
|
|
if (process.env.CUTOVER_TEST_SECRET) process.exit(9)
|
|
const request = JSON.parse(await Bun.stdin.text())
|
|
const lane = (name, source, code) => ({
|
|
_tag: "RecallLaneUnavailableV1",
|
|
lane: name,
|
|
source,
|
|
code,
|
|
message: "candidate probe unavailable",
|
|
})
|
|
const unavailable = [
|
|
lane("flowing-reflections", "flowing-memory-read-v1", "not-configured"),
|
|
lane("flowing-observations", "flowing-memory-read-v1", "not-configured"),
|
|
lane("curated-pages", "critical-db-curated", "store-unavailable"),
|
|
]
|
|
console.log(JSON.stringify({
|
|
ok: false,
|
|
command: "joelclaw recall",
|
|
result: {
|
|
adapter: "flowing-memory-recall",
|
|
composed: {
|
|
_tag: "ComposedRecallResultV1",
|
|
schemaVersion: 1,
|
|
request,
|
|
resolvedScope: request.scope,
|
|
resolvedAccess: request.access,
|
|
lanes: {
|
|
flowingReflections: unavailable[0],
|
|
flowingObservations: unavailable[1],
|
|
curatedPages: unavailable[2],
|
|
},
|
|
unavailable,
|
|
},
|
|
},
|
|
next_actions: [],
|
|
}))
|
|
process.exit(3)
|
|
`
|
|
}
|
|
|
|
function fixture() {
|
|
const root = mkdtempSync(join(tmpdir(), "recall-cutover-"))
|
|
const configPath = join(root, "config.toml")
|
|
const binaryPath = join(root, "joelclaw")
|
|
const receiptPath = join(root, "receipt.json")
|
|
const rollbackRoot = join(root, "rollback")
|
|
const artifactPath = join(root, "flowing-memory-read")
|
|
const candidateBinaryPath = join(root, "candidate-joelclaw")
|
|
const credentialExecutablePath = join(root, "secrets")
|
|
const credentialArgvPath = join(root, "credential-argv.txt")
|
|
writeFileSync(binaryPath, "installed binary fixture")
|
|
writeFileSync(candidateBinaryPath, candidateSource())
|
|
chmodSync(candidateBinaryPath, 0o700)
|
|
writeFileSync(
|
|
credentialExecutablePath,
|
|
`#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(credentialArgvPath)}\nprintf '%s' ${JSON.stringify(SECRET_SENTINEL)}\nprintf '%s' ${JSON.stringify(SECRET_SENTINEL)} >&2\nexit 0\n`,
|
|
)
|
|
chmodSync(credentialExecutablePath, 0o700)
|
|
const candidateBinarySha256 = createHash("sha256")
|
|
.update(readFileSync(candidateBinaryPath))
|
|
.digest("hex")
|
|
writeFileSync(artifactPath, "release fixture")
|
|
writeFileSync(
|
|
configPath,
|
|
[
|
|
"# preserve top comment",
|
|
"[capabilities.otel] # preserve section comment",
|
|
'adapter = "clickhouse-otel"',
|
|
"",
|
|
"[capabilities.recall]",
|
|
"# preserve recall comment",
|
|
'adapter = "typesense-recall" # preserve inline comment',
|
|
"custom_timeout_ms = 4321",
|
|
"",
|
|
"[[unrelated.items]]",
|
|
'name = "preserve-array-table"',
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
return {
|
|
root,
|
|
configPath,
|
|
binaryPath,
|
|
receiptPath,
|
|
rollbackRoot,
|
|
artifactPath,
|
|
candidateBinaryPath,
|
|
candidateBinarySha256,
|
|
credentialExecutablePath,
|
|
credentialArgvPath,
|
|
}
|
|
}
|
|
|
|
const verifiedRelease = (artifactPath: string) => ({
|
|
artifactPath,
|
|
digest: PINNED_READ_ARTIFACT_SHA256,
|
|
})
|
|
|
|
describe("recall cutover config", () => {
|
|
test("updates only recall keys while preserving comments and unrelated TOML", () => {
|
|
const paths = fixture()
|
|
const before = readFileSync(paths.configPath, "utf8")
|
|
const updated = updateRecallConfig({
|
|
current: before,
|
|
adapter: "flowing-memory-recall",
|
|
readExecutable: "/private/release/read",
|
|
})
|
|
expect(updated).toContain("# preserve top comment")
|
|
expect(updated).toContain("[capabilities.otel] # preserve section comment")
|
|
expect(updated).toContain("# preserve recall comment")
|
|
expect(updated).toContain("custom_timeout_ms = 4321")
|
|
expect(updated).toContain('[[unrelated.items]]\nname = "preserve-array-table"')
|
|
expect(updated).toContain('adapter = "flowing-memory-recall" # preserve inline comment')
|
|
expect(updated).toContain('read_executable = "/private/release/read"')
|
|
expect(updated).toContain(
|
|
`credential_secret_name = "${FLOWING_MEMORY_RUNTIME_DATABASE_SECRET_NAME}"`,
|
|
)
|
|
expect(updated).toContain('credential_format = "raw"')
|
|
const parsed = parseMinimalToml(updated) as {
|
|
capabilities?: { recall?: { enabled?: unknown; custom_timeout_ms?: unknown } }
|
|
}
|
|
expect(parsed.capabilities?.recall?.enabled).toBe(true)
|
|
expect(parsed.capabilities?.recall?.custom_timeout_ms).toBe(4321)
|
|
})
|
|
|
|
test("refuses malformed or duplicate TOML instead of reformatting it", () => {
|
|
expect(() =>
|
|
updateRecallConfig({
|
|
current: '[capabilities.recall\nadapter = "typesense-recall"\n',
|
|
adapter: "flowing-memory-recall",
|
|
}),
|
|
).toThrow("config TOML is malformed")
|
|
expect(() =>
|
|
updateRecallConfig({
|
|
current:
|
|
'[capabilities.recall]\nadapter = "typesense-recall"\n[capabilities.recall]\nadapter = "flowing-memory-recall"\n',
|
|
adapter: "flowing-memory-recall",
|
|
}),
|
|
).toThrow("config TOML is malformed")
|
|
expect(() =>
|
|
updateRecallConfig({
|
|
current: '[unrelated]\nvalue = ["unterminated"\n',
|
|
adapter: "flowing-memory-recall",
|
|
}),
|
|
).toThrow("config TOML is malformed")
|
|
})
|
|
|
|
test("generated flowing settings satisfy the production port", () => {
|
|
const release = testRelease()
|
|
const updated = updateRecallConfig({
|
|
current: "",
|
|
adapter: "flowing-memory-recall",
|
|
readExecutable: release.executable,
|
|
})
|
|
const parsed = parseMinimalToml(updated) as {
|
|
capabilities?: { recall?: { adapters?: Record<string, Record<string, unknown>> } }
|
|
}
|
|
const settings = parsed.capabilities?.recall?.adapters?.["flowing-memory-recall"]
|
|
const resolved = resolveFlowingRecallPortConfig({
|
|
settings,
|
|
trustedReleaseRoot: release.root,
|
|
expectedArtifactSha256: release.sha256,
|
|
})
|
|
expect(resolved.ok).toBe(true)
|
|
})
|
|
|
|
test("dry run verifies private stdin, flowing adapter, and exit 3 without writes", () => {
|
|
const paths = fixture()
|
|
const before = readFileSync(paths.configPath, "utf8")
|
|
const result = runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: true,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
})
|
|
expect(result.previousAdapter).toBe("typesense-recall")
|
|
expect(result.nextAdapter).toBe("flowing-memory-recall")
|
|
expect(result.installedBinaryDigest).not.toBe(result.previousBinaryDigest)
|
|
expect(result.wroteConfig).toBe(false)
|
|
expect(readFileSync(paths.configPath, "utf8")).toBe(before)
|
|
})
|
|
|
|
test("missing credential blocks apply before binary, config, journal, or backup changes", () => {
|
|
const paths = fixture()
|
|
writeFileSync(
|
|
paths.credentialExecutablePath,
|
|
`#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(paths.credentialArgvPath)}\nprintf '%s' ${JSON.stringify(SECRET_SENTINEL)}\nprintf '%s' ${JSON.stringify(SECRET_SENTINEL)} >&2\nexit 17\n`,
|
|
)
|
|
chmodSync(paths.credentialExecutablePath, 0o700)
|
|
const binaryBefore = readFileSync(paths.binaryPath)
|
|
const configBefore = readFileSync(paths.configPath)
|
|
|
|
let caught: unknown
|
|
try {
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: false,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
})
|
|
} catch (error) {
|
|
caught = error
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error)
|
|
expect((caught as Error).message).toBe("flowing recall credential preflight failed")
|
|
expect(String(caught)).not.toContain(SECRET_SENTINEL)
|
|
expect(readFileSync(paths.binaryPath)).toEqual(binaryBefore)
|
|
expect(readFileSync(paths.configPath)).toEqual(configBefore)
|
|
expect(existsSync(paths.receiptPath)).toBe(false)
|
|
expect(existsSync(paths.rollbackRoot)).toBe(false)
|
|
|
|
const credentialArgv = readFileSync(paths.credentialArgvPath, "utf8")
|
|
expect(credentialArgv).toContain(FLOWING_MEMORY_RUNTIME_DATABASE_SECRET_NAME)
|
|
expect(credentialArgv).not.toContain(SECRET_SENTINEL)
|
|
})
|
|
|
|
test("allows a 0600 config under an owner-owned non-writable 0755 parent", () => {
|
|
const paths = fixture()
|
|
const parent = join(paths.root, "owner-parent")
|
|
const configPath = join(parent, "config.toml")
|
|
mkdirSync(parent)
|
|
writeFileSync(configPath, "")
|
|
chmodSync(parent, 0o755)
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: false,
|
|
...paths,
|
|
configPath,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
})
|
|
expect(statSync(parent).mode & 0o777).toBe(0o755)
|
|
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
|
})
|
|
|
|
test("refuses a group-writable output parent without chmodding it", () => {
|
|
const paths = fixture()
|
|
const parent = join(paths.root, "group-writable-parent")
|
|
const configPath = join(parent, "config.toml")
|
|
mkdirSync(parent)
|
|
writeFileSync(configPath, "")
|
|
chmodSync(parent, 0o775)
|
|
expect(() =>
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: false,
|
|
...paths,
|
|
configPath,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
}),
|
|
).toThrow("not writable by group or other")
|
|
expect(statSync(parent).mode & 0o777).toBe(0o775)
|
|
})
|
|
|
|
test("candidate probe receives no ambient secret and rejects a help-only fake", () => {
|
|
const paths = fixture()
|
|
process.env.CUTOVER_TEST_SECRET = "must-not-reach-candidate"
|
|
try {
|
|
expect(() =>
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: true,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
}),
|
|
).not.toThrow()
|
|
} finally {
|
|
delete process.env.CUTOVER_TEST_SECRET
|
|
}
|
|
|
|
writeFileSync(paths.candidateBinaryPath, "#!/bin/sh\nprintf '%s\\n' '--request-file'\n")
|
|
chmodSync(paths.candidateBinaryPath, 0o700)
|
|
const digest = createHash("sha256")
|
|
.update(readFileSync(paths.candidateBinaryPath))
|
|
.digest("hex")
|
|
expect(() =>
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: true,
|
|
...paths,
|
|
candidateBinarySha256: digest,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
}),
|
|
).toThrow("private composed recall probe")
|
|
})
|
|
|
|
test("an active journal blocks repeated cutover until rollback", () => {
|
|
const paths = fixture()
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: false,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
})
|
|
expect(() =>
|
|
runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: true,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
}),
|
|
).toThrow("active recall cutover journal")
|
|
})
|
|
|
|
test("cutover and rollback preserve config and restore the exact binary", () => {
|
|
const paths = fixture()
|
|
const candidate = readFileSync(paths.candidateBinaryPath, "utf8")
|
|
const cutoverResult = runRecallCutover({
|
|
mode: "cutover",
|
|
dryRun: false,
|
|
...paths,
|
|
verifiedRelease: verifiedRelease(paths.artifactPath),
|
|
now: new Date("2026-08-23T00:00:00.000Z"),
|
|
})
|
|
expect(JSON.stringify(cutoverResult)).not.toContain(SECRET_SENTINEL)
|
|
const cutoverConfig = readFileSync(paths.configPath, "utf8")
|
|
expect(cutoverConfig).toContain('adapter = "flowing-memory-recall"')
|
|
expect(cutoverConfig).toContain("custom_timeout_ms = 4321")
|
|
expect(statSync(paths.configPath).mode & 0o777).toBe(0o600)
|
|
expect(statSync(paths.receiptPath).mode & 0o777).toBe(0o600)
|
|
const receiptBody = readFileSync(paths.receiptPath, "utf8")
|
|
expect(receiptBody).not.toContain(SECRET_SENTINEL)
|
|
const receipt = JSON.parse(receiptBody)
|
|
expect(receipt.state).toBe("active")
|
|
const credentialArgv = readFileSync(paths.credentialArgvPath, "utf8")
|
|
expect(credentialArgv).toContain(FLOWING_MEMORY_RUNTIME_DATABASE_SECRET_NAME)
|
|
expect(credentialArgv).not.toContain(SECRET_SENTINEL)
|
|
expect(readFileSync(paths.binaryPath, "utf8")).toBe(candidate)
|
|
|
|
runRecallCutover({
|
|
mode: "rollback",
|
|
dryRun: false,
|
|
...paths,
|
|
now: new Date("2026-08-23T01:00:00.000Z"),
|
|
})
|
|
const rolledBack = readFileSync(paths.configPath, "utf8")
|
|
expect(rolledBack).toContain('adapter = "typesense-recall"')
|
|
expect(rolledBack).toContain(`read_executable = "${paths.artifactPath}"`)
|
|
expect(rolledBack).toContain("custom_timeout_ms = 4321")
|
|
expect(rolledBack).toContain("[[unrelated.items]]")
|
|
expect(readFileSync(paths.binaryPath, "utf8")).toBe("installed binary fixture")
|
|
expect(JSON.parse(readFileSync(paths.receiptPath, "utf8")).state).toBe("rolled-back")
|
|
})
|
|
})
|