Skip to content

Commit 14dac27

Browse files
committed
fix(omo-native): prepare the installed engine on launch when postinstall never ran
postinstall is skipped under ignore-scripts=true and by Bun's untrusted-postinstall default, so such installs ran without the RPC stream guard and advertised claude-cli/2.1.251. The preparation moves into bin/lib/engine-prepare.js and claude-code-floor.js, stamps the engine tree with .omo-engine-prepared, and the launcher prepares an unstamped engine before every engine start. Refs #8713
1 parent 0b7764e commit 14dac27

9 files changed

Lines changed: 259 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Both agents now fall back in this order: `kimi-for-coding-highspeed` (off), `gpt
1919

2020
### Fixed
2121

22+
**Claude subscription sessions run and advertise Claude Code 2.1.280 on every install.** ([#8713](https://github.com/code-yeongyu/oh-my-openagent/issues/8713))
23+
24+
Beta.85 still shipped Claude Agent SDK 0.3.278, whose bundled Claude Code 2.1.278 is older than the 2.1.280 that Claude Opus 5.5 requires. Installs made with `ignore-scripts=true` in `~/.npmrc`, or through a Bun install that blocked the package's postinstall, also skipped the step that raises the advertised version, so they kept sending `claude-cli/2.1.251`. The engine now pins the SDK at 0.3.280 and declares 2.1.280 itself. The first `omo` launch prepares an engine that install scripts never touched and records it in the engine directory, so later launches skip the work. If that preparation fails, `omo` prints the reinstall command and starts anyway. A newer Claude Code on your PATH (after `claude update`, for example) now runs instead of the bundled copy, and `CLAUDE_CODE_EXECUTABLE` still overrides both.
25+
2226
**`omob` can build an engine version before its workspace packages reach npm.** The development build now uses the dependencies already packed into its local engine tarball instead of asking Bun to resolve their unpublished versions from the registry. Platform-specific optional packages still install normally, and an incomplete bundle fails explicitly rather than fetching a replacement.
2327

2428
**CI update-checker tests no longer depend on sibling test order.** An unnecessary module mock leaked a fixed version into the registry-channel tests, failing all five assertions when the hook tests ran first. The hook now uses only its existing injected stub; runtime update behavior is unchanged. ([#8678](https://github.com/code-yeongyu/oh-my-openagent/issues/8678))

‎packages/omo-native/AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ omo-senpi plugin payload produced by `bun run build:omo-native` (gitignored, nev
3434
engines into stale (interactive, PPID 1), attached and managed (`--mode`), and
3535
`reapStaleEngines` terminates ONLY explicitly named pids that are still stale at request time.
3636
Pattern-killing is forbidden.
37-
- `rpc-stream-errors.js` - postinstall preparation of the installed engine's stdio RPC serializer. A malformed streamed event produces a failed `prompt` response with `errorCode: invalid_stream_event` and shuts down with exit 1. The same preparation runs after an omob engine swap; repeated preparation is idempotent, and a missing RPC target, missing required binding (including `shutdown`), or unsupported serializer shape fails installation rather than silently missing the guard. Binding checks also run on already-prepared code.
37+
- `engine-prepare.js` / `claude-code-floor.js` - the installed-engine preparation (Claude Code UA floor, compile-safe css-tree data, RPC stream guard). postinstall (`bin/senpi-patch.mjs`) runs it and stamps the engine tree with `.omo-engine-prepared` (the omo version); the launcher runs `ensureEnginePrepared` before every engine start so an install whose scripts never ran (`ignore-scripts=true`, Bun's blocked postinstalls) is prepared on first launch (#8713). A failure warns with the reinstall command and never blocks the launch.
38+
- `rpc-stream-errors.js` - postinstall/launch preparation of the installed engine's stdio RPC serializer. A malformed streamed event produces a failed `prompt` response with `errorCode: invalid_stream_event` and shuts down with exit 1. The same preparation runs after an omob engine swap; repeated preparation is idempotent, and a missing RPC target, missing required binding (including `shutdown`), or unsupported serializer shape fails installation rather than silently missing the guard. Binding checks also run on already-prepared code.
3839
- `package-paths.js`, `provider-map.json`, `legacy-bun-global-migration.js`
3940
- **agent state lives in ONE canonical directory: `~/.omo/agent`.** `bin/lib/agent-dir.js` owns that answer (`canonicalAgentDir`), and the launcher, `omo doctor`, `omo setup` and the locally installed launcher (`packages/omo-senpi/src/install/local-launcher.ts`) all resolve it from there - never by composing their own default. An explicit `OMO_CODING_AGENT_DIR` (or legacy `SENPI_CODING_AGENT_DIR` / `PI_CODING_AGENT_DIR`) still wins, and `adoptLegacyFlatState` carries state left in the pre-unification flat `~/.omo` layout forward once, so unifying the location never reads as another reset.
4041
- `bin/omo-agent-toolkit.js` - internal delegate to the staged toolkit runtime, NOT an npm bin
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2+
import { join } from "node:path"
3+
4+
const claudeCodeVersionRelative = "node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js"
5+
const claudeCodeVersionPattern = /const claudeCodeVersion = "(\d+)\.(\d+)\.(\d+)";/
6+
// Claude Opus 5.5 rejects OAuth requests advertising Claude Code below 2.1.280 (claude_code_version_too_old).
7+
export const claudeCodeVersionFloor = "2.1.280"
8+
const [floorMajor, floorMinor, floorPatch] = claudeCodeVersionFloor.split(".").map(Number)
9+
10+
function isBelowFloor([major, minor, patch]) {
11+
return major < floorMajor ||
12+
(major === floorMajor && (minor < floorMinor || (minor === floorMinor && patch < floorPatch)))
13+
}
14+
15+
function floorPiAi(senpiRoot) {
16+
const path = join(senpiRoot, claudeCodeVersionRelative)
17+
if (!existsSync(path)) throw new Error(`omo-ai: installed Senpi target is missing: ${claudeCodeVersionRelative}`)
18+
const source = readFileSync(path, "utf8")
19+
const match = claudeCodeVersionPattern.exec(source)
20+
if (match === null) throw new Error(`omo-ai: unsupported Senpi ${claudeCodeVersionRelative}`)
21+
if (!isBelowFloor(match.slice(1).map(Number))) return
22+
writeFileSync(path, source.replace(claudeCodeVersionPattern, `const claudeCodeVersion = "${claudeCodeVersionFloor}";`))
23+
}
24+
25+
// The launcher runs the engine's pre-linked dist/bundle/cli.js whenever it exists, and that bundle
26+
// inlines its own claudeCodeVersion, so the pi-ai file above never reaches the running engine.
27+
// Every bundled declaration gets the same floor; only the version string is rewritten.
28+
function floorEngineBundle(senpiRoot) {
29+
const bundleRelative = "dist/bundle"
30+
const bundlePath = join(senpiRoot, bundleRelative)
31+
if (!existsSync(bundlePath)) return
32+
const declarationPattern = /\bclaudeCodeVersion\s*=\s*"(\d+)\.(\d+)\.(\d+)"/g
33+
let declarations = 0
34+
for (const relative of readdirSync(bundlePath, { recursive: true })) {
35+
if (!relative.endsWith(".js")) continue
36+
const path = join(bundlePath, relative)
37+
const source = readFileSync(path, "utf8")
38+
let raised = false
39+
const next = source.replace(declarationPattern, (declaration, major, minor, patch) => {
40+
declarations++
41+
if (!isBelowFloor([major, minor, patch].map(Number))) return declaration
42+
raised = true
43+
return declaration.replace(/"[^"]*"$/, `"${claudeCodeVersionFloor}"`)
44+
})
45+
if (raised) writeFileSync(path, next)
46+
}
47+
if (declarations === 0) throw new Error(`omo-ai: unsupported Senpi ${bundleRelative}: no claudeCodeVersion declaration`)
48+
}
49+
50+
export function floorClaudeCodeVersion(senpiRoot) {
51+
floorPiAi(senpiRoot)
52+
floorEngineBundle(senpiRoot)
53+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
2+
import { join } from "node:path"
3+
import { floorClaudeCodeVersion } from "./claude-code-floor.js"
4+
import { prepareCompileSafeEngine } from "./compile-safe-engine.js"
5+
import { prepareRpcStreamErrors } from "./rpc-stream-errors.js"
6+
7+
// Written inside the engine tree, so reinstalling or upgrading the engine drops it with the tree.
8+
export const ENGINE_PREPARED_STAMP = ".omo-engine-prepared"
9+
10+
export function prepareInstalledEngine(senpiRoot) {
11+
floorClaudeCodeVersion(senpiRoot)
12+
prepareCompileSafeEngine(senpiRoot)
13+
prepareRpcStreamErrors(senpiRoot)
14+
}
15+
16+
export function writeEnginePreparedStamp(senpiRoot, omoVersion) {
17+
writeFileSync(join(senpiRoot, ENGINE_PREPARED_STAMP), `${omoVersion}\n`)
18+
}
19+
20+
function isPreparedFor(senpiRoot, omoVersion) {
21+
const stamp = join(senpiRoot, ENGINE_PREPARED_STAMP)
22+
return existsSync(stamp) && readFileSync(stamp, "utf8").trim() === omoVersion
23+
}
24+
25+
/**
26+
* postinstall prepares the engine, but it never runs under `ignore-scripts=true` or Bun's blocked
27+
* postinstalls (#8713). The launcher therefore prepares an unstamped engine before starting it.
28+
* A failure is reported with the reinstall command and never blocks the launch: an unprepared
29+
* engine still runs, only without the guards.
30+
*/
31+
export function ensureEnginePrepared({ senpiRoot, omoVersion, reinstallCommand, report = (line) => { process.stderr.write(line) } }) {
32+
if (isPreparedFor(senpiRoot, omoVersion)) return
33+
try {
34+
prepareInstalledEngine(senpiRoot)
35+
writeEnginePreparedStamp(senpiRoot, omoVersion)
36+
} catch (error) {
37+
report(`omo: could not prepare the installed engine (${error.message}); reinstall with: ${reinstallCommand}\n`)
38+
}
39+
}

‎packages/omo-native/bin/lib/launcher.js‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { delimiter, join } from "node:path"
44
import { spawnNode } from "./child-process.js"
55
import { runDaemonCommand } from "./daemon.js"
66
import { runDoctor } from "./doctor.js"
7+
import { ensureEnginePrepared } from "./engine-prepare.js"
78
import { migrateLegacyBunGlobalManifest } from "./legacy-bun-global-migration.js"
89
import { adoptLegacyFlatState, canonicalAgentDir } from "./agent-dir.js"
910
import { nearestNodeBin, packageManifest, packageRoot, readJson, resolveSenpi, updateTarget } from "./package-paths.js"
@@ -106,8 +107,18 @@ function senpiEnvironment(senpiRoot) {
106107
return env
107108
}
108109

109-
async function spawnSenpi(args, withExtension) {
110+
function preparedSenpi() {
110111
const senpi = resolveSenpi()
112+
ensureEnginePrepared({
113+
senpiRoot: senpi.packageRoot,
114+
omoVersion: packageManifest().version,
115+
reinstallCommand: updateTarget().command,
116+
})
117+
return senpi
118+
}
119+
120+
async function spawnSenpi(args, withExtension) {
121+
const senpi = preparedSenpi()
111122
const finalArgs = withExtension
112123
? ["--extension", join(packageRoot, "plugin"), ...args]
113124
: args
@@ -162,7 +173,7 @@ function setupSuggestionForLaunch() {
162173
* an exit code, and `spawnSync` is honest about a call that is expected to be this short.
163174
*/
164175
export function engineHostCall(engineArgs, options) {
165-
const senpi = resolveSenpi()
176+
const senpi = preparedSenpi()
166177
const result = spawnSync(process.execPath, [senpi.cliPath, ...engineArgs], {
167178
encoding: "utf8",
168179
env: { ...senpiEnvironment(senpi.packageRoot), ...options.env },
@@ -197,7 +208,7 @@ export async function runLauncher(args = process.argv.slice(2)) {
197208
// `omo daemon attach <launch args>`: the daemon is reachable, so this becomes a normal launch
198209
// whose environment points the engine at the shared socket instead of starting its own.
199210
if (typeof outcome === "object") {
200-
const senpi = resolveSenpi()
211+
const senpi = preparedSenpi()
201212
await spawnNode(senpi.cliPath, ["--extension", join(packageRoot, "plugin"), ...outcome.args], {
202213
env: { ...senpiEnvironment(senpi.packageRoot), ...outcome.env },
203214
})
Lines changed: 4 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
1+
import { existsSync, readFileSync } from "node:fs"
22
import { dirname, join } from "node:path"
33
import { createRequire } from "node:module"
44
import { fileURLToPath } from "node:url"
5-
import { prepareCompileSafeEngine } from "./lib/compile-safe-engine.js"
6-
import { prepareRpcStreamErrors } from "./lib/rpc-stream-errors.js"
5+
import { prepareInstalledEngine, writeEnginePreparedStamp } from "./lib/engine-prepare.js"
76

87
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
98
const require = createRequire(join(packageRoot, "package.json"))
@@ -24,52 +23,5 @@ try {
2423
throw new Error("omo-ai: unable to resolve the installed @code-yeongyu/senpi package", { cause: error })
2524
}
2625

27-
const claudeCodeVersionRelative = "node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js"
28-
const claudeCodeVersionPattern = /const claudeCodeVersion = "(\d+)\.(\d+)\.(\d+)";/
29-
// Claude Opus 5.5 rejects OAuth requests advertising Claude Code below 2.1.280 (claude_code_version_too_old).
30-
const claudeCodeVersionFloor = "2.1.280"
31-
const [floorMajor, floorMinor, floorPatch] = claudeCodeVersionFloor.split(".").map(Number)
32-
33-
function isBelowFloor([major, minor, patch]) {
34-
return major < floorMajor ||
35-
(major === floorMajor && (minor < floorMinor || (minor === floorMinor && patch < floorPatch)))
36-
}
37-
38-
const claudeCodeVersionPath = join(senpiRoot, claudeCodeVersionRelative)
39-
if (!existsSync(claudeCodeVersionPath)) throw new Error(`omo-ai: installed Senpi target is missing: ${claudeCodeVersionRelative}`)
40-
const claudeCodeSource = readFileSync(claudeCodeVersionPath, "utf8")
41-
const claudeCodeMatch = claudeCodeVersionPattern.exec(claudeCodeSource)
42-
if (claudeCodeMatch === null) throw new Error(`omo-ai: unsupported Senpi ${claudeCodeVersionRelative}`)
43-
if (isBelowFloor(claudeCodeMatch.slice(1).map(Number))) {
44-
writeFileSync(
45-
claudeCodeVersionPath,
46-
claudeCodeSource.replace(claudeCodeVersionPattern, `const claudeCodeVersion = "${claudeCodeVersionFloor}";`),
47-
)
48-
}
49-
50-
// The launcher runs the engine's pre-linked dist/bundle/cli.js whenever it exists, and that bundle
51-
// inlines its own claudeCodeVersion, so the pi-ai file above never reaches the running engine.
52-
// Every bundled declaration gets the same floor; only the version string is rewritten.
53-
const claudeCodeBundleRelative = "dist/bundle"
54-
const claudeCodeBundlePath = join(senpiRoot, claudeCodeBundleRelative)
55-
if (existsSync(claudeCodeBundlePath)) {
56-
const bundledDeclarationPattern = /\bclaudeCodeVersion\s*=\s*"(\d+)\.(\d+)\.(\d+)"/g
57-
let bundledDeclarations = 0
58-
for (const relative of readdirSync(claudeCodeBundlePath, { recursive: true })) {
59-
if (!relative.endsWith(".js")) continue
60-
const path = join(claudeCodeBundlePath, relative)
61-
const source = readFileSync(path, "utf8")
62-
let raised = false
63-
const next = source.replace(bundledDeclarationPattern, (declaration, major, minor, patch) => {
64-
bundledDeclarations++
65-
if (!isBelowFloor([major, minor, patch].map(Number))) return declaration
66-
raised = true
67-
return declaration.replace(/"[^"]*"$/, `"${claudeCodeVersionFloor}"`)
68-
})
69-
if (raised) writeFileSync(path, next)
70-
}
71-
if (bundledDeclarations === 0) throw new Error(`omo-ai: unsupported Senpi ${claudeCodeBundleRelative}: no claudeCodeVersion declaration`)
72-
}
73-
74-
prepareCompileSafeEngine(senpiRoot)
75-
prepareRpcStreamErrors(senpiRoot)
26+
prepareInstalledEngine(senpiRoot)
27+
writeEnginePreparedStamp(senpiRoot, JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version)

‎packages/omo-native/changes.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
## 2026-09-23 - the launcher prepares an engine postinstall never touched (#8713)
2+
3+
### What changed
4+
5+
`bin/senpi-patch.mjs` keeps resolving the engine root and now only calls `prepareInstalledEngine` and writes the stamp. The preparation moved into `bin/lib/engine-prepare.js` (orchestrator plus the `.omo-engine-prepared` stamp, holding the omo version, inside the engine tree) and `bin/lib/claude-code-floor.js` (the Claude Code UA floor, unchanged logic). `launcher.js` routes every engine start (`spawnSenpi`, `engineHostCall`, `omo daemon attach`) through `preparedSenpi()`, which calls `ensureEnginePrepared`: a matching stamp costs one small read; a missing or foreign stamp prepares and restamps; a failure prints `omo: could not prepare the installed engine (...); reinstall with: ...` and the launch continues.
6+
7+
### Why
8+
9+
postinstall is skipped under `ignore-scripts=true` and by Bun's untrusted-postinstall default, and nothing noticed: beta.85 installed that way ran without the RPC stream guard and advertised `claude-cli/2.1.251`.
10+
11+
### Why an extension could not handle it
12+
13+
The preparation rewrites the installed engine's files before the engine starts; no extension runs that early.
14+
15+
### Expected merge conflict zones
16+
17+
`bin/senpi-patch.mjs`, `bin/lib/engine-prepare.js`, `bin/lib/claude-code-floor.js`, the engine-start call sites in `bin/lib/launcher.js`, `test/packed-install.test.ts`.
18+
119
## 2026-09-23 - Claude Code UA floor reaches the bundled engine and rises to 2.1.280
220

321
### What changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import { dirname, join } from "node:path"
5+
import { ENGINE_PREPARED_STAMP, ensureEnginePrepared } from "../bin/lib/engine-prepare.js"
6+
7+
const PI_AI_MESSAGES = "node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js"
8+
const RPC_MODE = "dist/modes/rpc/rpc-mode.js"
9+
const OMO_VERSION = "5.0.0-0.beta.86"
10+
const REINSTALL = "npm i -g omo-ai@beta"
11+
12+
const roots: string[] = []
13+
14+
function uaSource(version: string): string {
15+
return `const claudeCodeVersion = "${version}";\nexport {}\n`
16+
}
17+
18+
function createEngine(uaVersion: string): string {
19+
const root = mkdtempSync(join(tmpdir(), "omo-engine-prepare-"))
20+
roots.push(root)
21+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "@code-yeongyu/senpi", version: "2026.9.23", type: "module" }))
22+
const rpcPath = join(root, RPC_MODE)
23+
mkdirSync(dirname(rpcPath), { recursive: true })
24+
writeFileSync(rpcPath, readFileSync(new URL("./modes/rpc/rpc-mode.js", import.meta.resolve("@code-yeongyu/senpi")), "utf8"))
25+
const messages = join(root, PI_AI_MESSAGES)
26+
mkdirSync(dirname(messages), { recursive: true })
27+
writeFileSync(messages, uaSource(uaVersion))
28+
return root
29+
}
30+
31+
function prepareOnce(root: string, reported: string[] = []) {
32+
ensureEnginePrepared({ senpiRoot: root, omoVersion: OMO_VERSION, reinstallCommand: REINSTALL, report: (line: string) => reported.push(line) })
33+
}
34+
35+
const readUa = (root: string) => readFileSync(join(root, PI_AI_MESSAGES), "utf8")
36+
const readStamp = (root: string) => readFileSync(join(root, ENGINE_PREPARED_STAMP), "utf8").trim()
37+
38+
afterEach(() => {
39+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
40+
})
41+
42+
describe("launcher engine preparation (#8713)", () => {
43+
describe("#given an engine that postinstall never prepared", () => {
44+
test("#then the launch prepares it and stamps it with the omo version", () => {
45+
const root = createEngine("2.1.251")
46+
prepareOnce(root)
47+
expect(readUa(root)).toBe(uaSource("2.1.280"))
48+
expect(readFileSync(join(root, RPC_MODE), "utf8")).toContain("invalid_stream_event")
49+
expect(readStamp(root)).toBe(OMO_VERSION)
50+
})
51+
})
52+
53+
describe("#given an engine already stamped for this omo version", () => {
54+
test("#then the launch leaves every engine file untouched", () => {
55+
const root = createEngine("2.1.251")
56+
prepareOnce(root)
57+
writeFileSync(join(root, PI_AI_MESSAGES), uaSource("2.1.100"))
58+
prepareOnce(root)
59+
expect(readUa(root)).toBe(uaSource("2.1.100"))
60+
})
61+
})
62+
63+
describe("#given an engine stamped by a different omo version", () => {
64+
test("#then the launch prepares it again and restamps it", () => {
65+
const root = createEngine("2.1.251")
66+
writeFileSync(join(root, ENGINE_PREPARED_STAMP), "5.0.0-0.beta.85\n")
67+
prepareOnce(root)
68+
expect(readUa(root)).toBe(uaSource("2.1.280"))
69+
expect(readStamp(root)).toBe(OMO_VERSION)
70+
})
71+
})
72+
73+
describe("#given an engine whose preparation fails", () => {
74+
test("#then the launch reports it with the reinstall command, does not throw, and leaves no stamp", () => {
75+
const root = createEngine("2.1.251")
76+
rmSync(join(root, RPC_MODE))
77+
const reported: string[] = []
78+
expect(() => prepareOnce(root, reported)).not.toThrow()
79+
expect(reported.join("")).toContain("rpc_patch_target_missing")
80+
expect(reported.join("")).toContain(REINSTALL)
81+
expect(existsSync(join(root, ENGINE_PREPARED_STAMP))).toBe(false)
82+
})
83+
})
84+
})

0 commit comments

Comments
 (0)