fix: guard against newer synced resources (#445)

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Colin Johnson 2026-03-15 00:58:18 -04:00 committed by GitHub
parent 16364c7dba
commit fe03743b08
4 changed files with 117 additions and 3 deletions

View file

@ -12,7 +12,7 @@ import {
import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
import { initResources, buildResourceLoader } from './resource-loader.js'
import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js'
import { ensureManagedTools } from './tool-bootstrap.js'
import { loadStoredEnvKeys } from './wizard.js'
import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js'
@ -35,6 +35,26 @@ interface CliFlags {
messages: string[]
}
function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
const currentVersion = process.env.GSD_VERSION || '0.0.0'
const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion)
if (!managedVersion) {
return
}
const yellow = '\x1b[33m'
const dim = '\x1b[2m'
const reset = '\x1b[0m'
const bold = '\x1b[1m'
process.stderr.write(
`[gsd] ${yellow}Version mismatch detected${reset}\n` +
`[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` +
`[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`,
)
process.exit(1)
}
function parseCliArgs(argv: string[]): CliFlags {
const flags: CliFlags = { extensions: [], messages: [] }
const args = argv.slice(2) // skip node + script
@ -232,6 +252,7 @@ if (isPrintMode) {
}
}
exitIfManagedResourcesAreNewer(agentDir)
initResources(agentDir)
const resourceLoader = new DefaultResourceLoader({
agentDir,
@ -316,6 +337,7 @@ const sessionManager = cliFlags.continue
? SessionManager.continueRecent(cwd, projectSessionsDir)
: SessionManager.create(cwd, projectSessionsDir)
exitIfManagedResourcesAreNewer(agentDir)
initResources(agentDir)
const resourceLoader = buildResourceLoader(agentDir)
await resourceLoader.reload()

View file

@ -3,6 +3,7 @@ import { homedir } from 'node:os'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { compareSemver } from './update-check.js'
// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree, changes with git branch).
@ -17,6 +18,11 @@ const distResources = join(packageRoot, 'dist', 'resources')
const srcResources = join(packageRoot, 'src', 'resources')
const resourcesDir = existsSync(distResources) ? distResources : srcResources
const bundledExtensionsDir = join(resourcesDir, 'extensions')
const resourceVersionManifestName = 'managed-resources.json'
interface ManagedResourceManifest {
gsdVersion: string
}
function isExtensionFile(name: string): boolean {
return name.endsWith('.ts') || name.endsWith('.js')
@ -82,6 +88,41 @@ function getExtensionKey(entryPath: string, extensionsDir: string): string {
return relPath.split(/[\\/]/)[0]
}
function getManagedResourceManifestPath(agentDir: string): string {
return join(agentDir, resourceVersionManifestName)
}
function getBundledGsdVersion(): string {
try {
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
} catch {
return process.env.GSD_VERSION || '0.0.0'
}
}
function writeManagedResourceManifest(agentDir: string): void {
const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion() }
writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest))
}
export function readManagedResourceVersion(agentDir: string): string | null {
try {
const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null
} catch {
return null
}
}
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
const managedVersion = readManagedResourceVersion(agentDir)
if (!managedVersion) {
return null
}
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
}
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
@ -116,6 +157,8 @@ export function initResources(agentDir: string): void {
if (existsSync(srcSkills)) {
cpSync(srcSkills, destSkills, { recursive: true, force: true })
}
writeManagedResourceManifest(agentDir)
}
/**

View file

@ -126,7 +126,7 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => {
// ═══════════════════════════════════════════════════════════════════════════
test("initResources syncs extensions, agents, and skills to target dir", async () => {
const { initResources } = await import("../resource-loader.ts");
const { initResources, readManagedResourceVersion } = await import("../resource-loader.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-resources-test-"));
const fakeAgentDir = join(tmp, "agent");
@ -146,6 +146,10 @@ test("initResources syncs extensions, agents, and skills to target dir", async (
// Skills synced
assert.ok(existsSync(join(fakeAgentDir, "skills")), "skills directory synced");
// Version manifest synced
const managedVersion = readManagedResourceVersion(fakeAgentDir);
assert.ok(managedVersion, "managed resource version written");
// Idempotent: run again, no crash
initResources(fakeAgentDir);
assert.ok(existsSync(join(fakeAgentDir, "extensions", "gsd", "index.ts")), "idempotent re-sync works");

View file

@ -12,7 +12,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createGunzip } from "node:zlib";
@ -229,3 +229,48 @@ test("gsd launches and loads extensions without errors", async () => {
"no ERR_MODULE_NOT_FOUND",
);
});
test("gsd exits early with a clear message when synced resources are newer than the binary", async () => {
const fakeHome = mkdtempSync(join(tmpdir(), "gsd-version-skew-"));
const fakeAgentDir = join(fakeHome, ".gsd", "agent");
mkdirSync(fakeAgentDir, { recursive: true });
writeFileSync(
join(fakeAgentDir, "managed-resources.json"),
JSON.stringify({ gsdVersion: "999.0.0" }),
);
try {
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
let stderr = "";
const child = spawn("node", ["dist/loader.js"], {
cwd: projectRoot,
env: {
...process.env,
HOME: fakeHome,
BRAVE_API_KEY: "test",
BRAVE_ANSWERS_KEY: "test",
CONTEXT7_API_KEY: "test",
JINA_API_KEY: "test",
TAVILY_API_KEY: "test",
},
stdio: ["pipe", "pipe", "pipe"],
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
child.stdin.end();
child.on("close", (code) => {
resolve({ code, stderr });
});
});
assert.equal(result.code, 1, "startup exits with code 1 on version skew");
assert.match(result.stderr, /Version mismatch detected/, "prints a friendly skew header");
assert.match(result.stderr, /npm install -g gsd-pi@latest|gsd update/, "prints upgrade guidance");
assert.doesNotMatch(result.stderr, /\[gsd\] Extension load error/, "fails before extension loading");
} finally {
rmSync(fakeHome, { recursive: true, force: true });
}
});