diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index c95a9919eeeb..774c04a5d027 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -476,8 +476,9 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.preflight.outputs.checkout_revision }} - fetch-depth: 1 + fetch-depth: 0 fetch-tags: false + filter: blob:none persist-credentials: false submodules: false diff --git a/scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs b/scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs index 1f12c2905a3d..0ef23e767e81 100644 --- a/scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs +++ b/scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs @@ -77,17 +77,14 @@ function extractStatusSection(text, title) { return stripAnsi(section.join("\n")); } -function readSharedAuthProfileStoreText(stateDir) { - const dbPath = path.join(stateDir, "state", "openclaw.sqlite"); +function readAuthProfileStoreText(dbPath, query, storeKey) { if (!fs.existsSync(dbPath)) { return ""; } let db; try { db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db - .prepare("SELECT store_json FROM auth_profile_stores WHERE store_key = ?") - .get("shared"); + const row = db.prepare(query).get(storeKey); return typeof row?.store_json === "string" ? row.store_json : ""; } catch { return ""; @@ -96,10 +93,50 @@ function readSharedAuthProfileStoreText(stateDir) { } } +function readCurrentAuthProfileStoreText(stateDir) { + const dbPath = path.join(stateDir, "state", "openclaw.sqlite"); + return readAuthProfileStoreText( + dbPath, + "SELECT store_json FROM auth_profile_stores WHERE store_key = ?", + "shared", + ); +} + +function readLegacyAuthProfileStoreText(stateDir) { + const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); + return readAuthProfileStoreText( + dbPath, + "SELECT store_json FROM auth_profile_store WHERE store_key = ?", + "primary", + ); +} + function assertOnboardState() { - const home = process.argv[3]; + const expectedLayout = process.argv[3]; + const home = process.argv[4]; + if (expectedLayout !== "legacy" && expectedLayout !== "current") { + throw new Error(`unsupported onboard auth profile store layout: ${expectedLayout}`); + } const stateDir = path.join(home, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); + + if (!fs.existsSync(configPath)) { + throw new Error("onboard did not write openclaw.json"); + } + const legacyAuthStoreText = readLegacyAuthProfileStoreText(stateDir); + const currentAuthStoreText = readCurrentAuthProfileStoreText(stateDir); + if (legacyAuthStoreText && currentAuthStoreText) { + throw new Error("onboard persisted mixed legacy and current auth profile stores"); + } + const observedLayout = legacyAuthStoreText ? "legacy" : currentAuthStoreText ? "current" : ""; + if (!observedLayout) { + throw new Error("onboard did not persist auth profile store"); + } + if (observedLayout !== expectedLayout) { + throw new Error( + `onboard persisted ${observedLayout} auth profile store; expected ${expectedLayout}`, + ); + } const legacyAuthDatabase = path.join( stateDir, "agents", @@ -107,14 +144,10 @@ function assertOnboardState() { "agent", "openclaw-agent.sqlite", ); - - if (!fs.existsSync(configPath)) { - throw new Error("onboard did not write openclaw.json"); - } - if (fs.existsSync(legacyAuthDatabase)) { + if (expectedLayout === "current" && fs.existsSync(legacyAuthDatabase)) { throw new Error("onboard created the retired main-agent auth database"); } - const authStoreText = readSharedAuthProfileStoreText(stateDir); + const authStoreText = expectedLayout === "legacy" ? legacyAuthStoreText : currentAuthStoreText; if (!authStoreText) { throw new Error("onboard did not persist auth profile store"); } diff --git a/scripts/e2e/npm-onboard-channel-agent-docker.sh b/scripts/e2e/npm-onboard-channel-agent-docker.sh index 53c18a79dce1..ea2fe21b2034 100644 --- a/scripts/e2e/npm-onboard-channel-agent-docker.sh +++ b/scripts/e2e/npm-onboard-channel-agent-docker.sh @@ -14,6 +14,7 @@ HOST_BUILD="${OPENCLAW_NPM_ONBOARD_HOST_BUILD:-1}" PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" CHANNEL="${OPENCLAW_NPM_ONBOARD_CHANNEL:-telegram}" USE_SOURCE_PLUGIN_PACKAGE="${OPENCLAW_NPM_ONBOARD_USE_SOURCE_PLUGIN_PACKAGE:-0}" +AUTH_PROFILE_STORE_CUTOVER_SHA="a8a9f284fb91af6a9d78fe66f9141eb01e009b21" JSON_ARTIFACT_MAX_BYTES="$( docker_e2e_read_positive_int_env OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES 1048576 )" @@ -24,6 +25,50 @@ run_log="" plugin_pack_dir="" plugin_package_args=() +resolve_auth_profile_store_layout() { + local selected_sha="${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}" + local source_sha + source_sha="$(git -C "$SOURCE_ROOT" rev-parse HEAD)" + if [ -z "$selected_sha" ]; then + selected_sha="$source_sha" + fi + if [[ ! "$selected_sha" =~ ^[0-9a-f]{40}$ ]] || [ "$source_sha" != "$selected_sha" ]; then + echo "Cannot bind npm onboarding auth layout to candidate $selected_sha at $source_sha." >&2 + return 1 + fi + + if [ "$(git -C "$SOURCE_ROOT" rev-parse --is-shallow-repository)" = "true" ]; then + git -C "$SOURCE_ROOT" fetch --no-tags --filter=blob:none --deepen=1024 origin \ + "$selected_sha" "$AUTH_PROFILE_STORE_CUTOVER_SHA" + elif ! git -C "$SOURCE_ROOT" cat-file -e "$AUTH_PROFILE_STORE_CUTOVER_SHA^{commit}"; then + git -C "$SOURCE_ROOT" fetch --no-tags --filter=blob:none --depth=1024 origin \ + "$AUTH_PROFILE_STORE_CUTOVER_SHA" + fi + + if ! git -C "$SOURCE_ROOT" merge-base "$AUTH_PROFILE_STORE_CUTOVER_SHA" "$selected_sha" \ + >/dev/null; then + echo "Cannot prove candidate $selected_sha auth layout ancestry relative to $AUTH_PROFILE_STORE_CUTOVER_SHA." >&2 + return 1 + fi + set +e + git -C "$SOURCE_ROOT" merge-base --is-ancestor \ + "$AUTH_PROFILE_STORE_CUTOVER_SHA" "$selected_sha" + local ancestry_status=$? + set -e + if [ "$ancestry_status" -eq 0 ]; then + printf '%s\n' "current" + elif [ "$ancestry_status" -eq 1 ]; then + # Frozen release branches can diverge before the cutover; absence of the + # cutover commit is the legacy contract, not a requirement to follow main. + printf '%s\n' "legacy" + else + echo "Cannot determine candidate $selected_sha auth layout ancestry relative to $AUTH_PROFILE_STORE_CUTOVER_SHA." >&2 + return 1 + fi +} + +AUTH_PROFILE_STORE_LAYOUT="$(resolve_auth_profile_store_layout)" + cleanup() { if [ -n "${PACKAGE_TGZ:-}" ]; then docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ" @@ -103,6 +148,7 @@ echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..." if ! docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \ + -e "OPENCLAW_NPM_ONBOARD_AUTH_LAYOUT=$AUTH_PROFILE_STORE_LAYOUT" \ -e "OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES=$JSON_ARTIFACT_MAX_BYTES" \ -e "OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES=$STATUS_TEXT_MAX_BYTES" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ @@ -208,7 +254,8 @@ openclaw onboard --non-interactive --accept-risk \ --skip-health \ --json >/tmp/openclaw-onboard.json -node scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs assert-onboard-state "$HOME" +node scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs \ + assert-onboard-state "$OPENCLAW_NPM_ONBOARD_AUTH_LAYOUT" "$HOME" openclaw_e2e_assert_dep_absent "$DEP_SENTINEL" "$HOME/.openclaw" diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 0de574b0512a..f895d548df11 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2716,6 +2716,21 @@ docker_e2e_docker_run_cmd run demo expect(script).not.toContain("/tmp/openclaw-channel-add.log"); }); + it("binds npm onboarding auth layout to the exact candidate ancestry", () => { + const script = readFileSync(NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH, "utf8"); + + expectTextToIncludeAll(script, [ + 'AUTH_PROFILE_STORE_CUTOVER_SHA="a8a9f284fb91af6a9d78fe66f9141eb01e009b21"', + 'selected_sha="${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}"', + "fetch --no-tags --filter=blob:none --deepen=1024 origin", + 'merge-base "$AUTH_PROFILE_STORE_CUTOVER_SHA" "$selected_sha"', + 'merge-base --is-ancestor \\\n "$AUTH_PROFILE_STORE_CUTOVER_SHA" "$selected_sha"', + "OPENCLAW_NPM_ONBOARD_AUTH_LAYOUT=$AUTH_PROFILE_STORE_LAYOUT", + ]); + expect(script).toContain('elif [ "$ancestry_status" -eq 1 ]; then'); + expect(script).toContain('if ! git -C "$SOURCE_ROOT" merge-base'); + }); + it("keeps real-TTY onboarding drivers aligned with the first-agent prompt", () => { expectOrderedScriptFragments(readFileSync(RELEASE_TYPED_ONBOARDING_SCENARIO_PATH, "utf8"), [ 'wait_for_log "Continue?"', diff --git a/test/scripts/npm-onboard-channel-agent-assertions.test.ts b/test/scripts/npm-onboard-channel-agent-assertions.test.ts index e725794f655f..7d4bd394ab7e 100644 --- a/test/scripts/npm-onboard-channel-agent-assertions.test.ts +++ b/test/scripts/npm-onboard-channel-agent-assertions.test.ts @@ -37,7 +37,7 @@ function writeOnboardConfig(home: string): void { ); } -function writeSharedAuthProfileStoreSqlite(home: string, store: unknown): void { +function writeCurrentAuthProfileStoreSqlite(home: string, store: unknown): void { const stateDir = path.join(home, ".openclaw", "state"); fs.mkdirSync(stateDir, { recursive: true }); const db = new DatabaseSync(path.join(stateDir, "openclaw.sqlite")); @@ -60,6 +60,29 @@ function writeSharedAuthProfileStoreSqlite(home: string, store: unknown): void { } } +function writeLegacyAuthProfileStoreSqlite(home: string, store: unknown): void { + const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + const db = new DatabaseSync(path.join(agentDir, "openclaw-agent.sqlite")); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS auth_profile_store ( + store_key TEXT NOT NULL PRIMARY KEY, + store_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + db.prepare( + ` + INSERT INTO auth_profile_store (store_key, store_json, updated_at) + VALUES (?, ?, ?) + `, + ).run("primary", JSON.stringify(store), Date.now()); + } finally { + db.close(); + } +} + function runAssert(home: string, channel: string, ...tokens: string[]) { return spawnSync( process.execPath, @@ -75,8 +98,8 @@ function runAssert(home: string, channel: string, ...tokens: string[]) { ); } -function runOnboardAssert(home: string) { - return spawnSync(process.execPath, [assertionsPath, "assert-onboard-state", home], { +function runOnboardAssert(home: string, layout: "current" | "legacy") { + return spawnSync(process.execPath, [assertionsPath, "assert-onboard-state", layout, home], { encoding: "utf8", env: { ...process.env, @@ -218,33 +241,36 @@ describe("npm onboard channel agent assertions", () => { } }); - it("validates OpenAI env refs from the shared SQLite auth profile store", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); - const agentDir = path.join(tempDir, ".openclaw", "agents", "main", "agent"); + it.each([ + ["legacy", writeLegacyAuthProfileStoreSqlite], + ["current", writeCurrentAuthProfileStoreSqlite], + ] as const)( + "validates OpenAI env refs from the %s SQLite auth profile store", + (layout, writeStore) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); - try { - writeOnboardConfig(tempDir); - writeSharedAuthProfileStoreSqlite(tempDir, { - version: 1, - profiles: { - "openai:api-key": { - type: "api_key", - provider: "openai", - keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + try { + writeOnboardConfig(tempDir); + writeStore(tempDir, { + version: 1, + profiles: { + "openai:api-key": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, }, - }, - }); + }); - const result = runOnboardAssert(tempDir); + const result = runOnboardAssert(tempDir, layout); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(fs.existsSync(agentDir)).toBe(false); - expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); - } finally { - fs.rmSync(tempDir, { force: true, recursive: true }); - } - }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }, + ); it("rejects auth profile stores without a usable OpenAI env ref", () => { const cases: unknown[] = [ @@ -262,9 +288,9 @@ describe("npm onboard channel agent assertions", () => { try { writeOnboardConfig(tempDir); - writeSharedAuthProfileStoreSqlite(tempDir, store); + writeCurrentAuthProfileStoreSqlite(tempDir, store); - const result = runOnboardAssert(tempDir); + const result = runOnboardAssert(tempDir, "current"); expect(result.status).not.toBe(0); expect(result.stderr).toContain("auth profile did not persist OPENAI_API_KEY env ref"); @@ -278,7 +304,7 @@ describe("npm onboard channel agent assertions", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); try { writeOnboardConfig(tempDir); - writeSharedAuthProfileStoreSqlite(tempDir, { + writeCurrentAuthProfileStoreSqlite(tempDir, { version: 1, profiles: { "openai:api-key": { @@ -289,7 +315,7 @@ describe("npm onboard channel agent assertions", () => { }, }); - const result = runOnboardAssert(tempDir); + const result = runOnboardAssert(tempDir, "current"); expect(result.status).not.toBe(0); expect(result.stderr).toContain("auth profile persisted the raw OpenAI test key"); @@ -298,13 +324,75 @@ describe("npm onboard channel agent assertions", () => { } }); - it("rejects a fresh install that recreates the retired main-agent auth database", () => { + it("rejects mixed legacy and current auth profile stores", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); + + try { + writeOnboardConfig(tempDir); + const store = { + version: 1, + profiles: { + "openai:api-key": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }; + writeLegacyAuthProfileStoreSqlite(tempDir, store); + writeCurrentAuthProfileStoreSqlite(tempDir, store); + + const result = runOnboardAssert(tempDir, "current"); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "onboard persisted mixed legacy and current auth profile stores", + ); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it.each([ + ["current", "legacy", writeCurrentAuthProfileStoreSqlite], + ["legacy", "current", writeLegacyAuthProfileStoreSqlite], + ] as const)( + "rejects the %s layout when the candidate requires %s", + (observedLayout, expectedLayout, writeStore) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); + + try { + writeOnboardConfig(tempDir); + writeStore(tempDir, { + version: 1, + profiles: { + "openai:api-key": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }); + + const result = runOnboardAssert(tempDir, expectedLayout); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `onboard persisted ${observedLayout} auth profile store; expected ${expectedLayout}`, + ); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }, + ); + + it("keeps the current-layout ban on the retired main-agent database", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); const legacyAgentDir = path.join(tempDir, ".openclaw", "agents", "main", "agent"); try { writeOnboardConfig(tempDir); - writeSharedAuthProfileStoreSqlite(tempDir, { + writeCurrentAuthProfileStoreSqlite(tempDir, { version: 1, profiles: { "openai:api-key": { @@ -317,7 +405,7 @@ describe("npm onboard channel agent assertions", () => { fs.mkdirSync(legacyAgentDir, { recursive: true }); new DatabaseSync(path.join(legacyAgentDir, "openclaw-agent.sqlite")).close(); - const result = runOnboardAssert(tempDir); + const result = runOnboardAssert(tempDir, "current"); expect(result.status).not.toBe(0); expect(result.stderr).toContain("onboard created the retired main-agent auth database"); @@ -326,6 +414,21 @@ describe("npm onboard channel agent assertions", () => { } }); + it("rejects a missing auth profile store", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-onboard-assertions-")); + + try { + writeOnboardConfig(tempDir); + + const result = runOnboardAssert(tempDir, "legacy"); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("onboard did not persist auth profile store"); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("validates channel tokens in their canonical config fields", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-assertions-")); try { diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 5fa5d4599386..e8f1291a9d04 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -754,6 +754,16 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(extensionShard.strategy.matrix).toBe( "${{ fromJson(needs.preflight.outputs.plugin_prerelease_extension_matrix) }}", ); + expect( + extensionShard.steps.find((step: WorkflowStep) => step.name === "Checkout").with, + ).toEqual({ + "fetch-depth": 0, + "fetch-tags": false, + filter: "blob:none", + "persist-credentials": false, + ref: "${{ needs.preflight.outputs.checkout_revision }}", + submodules: false, + }); expect( extensionShard.steps.find((step: WorkflowStep) => step.name === "Run extension shard").run, ).toContain("--retry=1");