@cocorograph/hub-agent 0.8.49 → 0.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.49",
3
+ "version": "0.8.50",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,198 @@
1
+ import path from "node:path"
2
+ import { lstat } from "node:fs/promises"
3
+
4
+ import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
5
+
6
+ export const ARTIFACT_TEMPLATE_CATALOG_MAX_ITEMS = 100
7
+ export const ARTIFACT_TEMPLATE_CATALOG_MAX_BYTES = 24_000
8
+ export const ARTIFACT_TEMPLATE_DESCRIPTION_MAX_CODEPOINTS = 600
9
+
10
+ const TEMPLATE_NAME_RE = /^(?:[^:]+:)?artifact-template-[a-z0-9][a-z0-9._-]*$/
11
+ const PICKER_CONFIG_KEY = "mcp_servers.openai_artifact_template_picker"
12
+ const DARWIN_RESOURCES_PATH = "/Applications/ChatGPT.app/Contents/Resources"
13
+
14
+ export class CodexArtifactTemplatePickerError extends Error {
15
+ constructor(code, message, options = {}) {
16
+ super(message, options)
17
+ this.name = "CodexArtifactTemplatePickerError"
18
+ this.code = code
19
+ }
20
+ }
21
+
22
+ function fail(code, message, cause) {
23
+ throw new CodexArtifactTemplatePickerError(code, message, cause ? { cause } : undefined)
24
+ }
25
+
26
+ function diagnostic(code, detail = {}) {
27
+ return { code, ...detail }
28
+ }
29
+
30
+ function priorityOf(skillName) {
31
+ if (skillName.startsWith("openai-templates:")) return 2
32
+ return skillName.includes(":") ? 1 : 0
33
+ }
34
+
35
+ function normalizedPathKey(skillPath, platform) {
36
+ // Desktop does not establish case-folding as part of its contract. Normalize separators and
37
+ // dot segments only, preserving case and the original path carried in the catalog.
38
+ return platform === "win32" ? path.win32.normalize(skillPath) : path.posix.normalize(skillPath)
39
+ }
40
+
41
+ function codepointPrefix(value, limit) {
42
+ return Array.from(value).slice(0, limit).join("")
43
+ }
44
+
45
+ function optionalTrimmed(value) {
46
+ return typeof value === "string" ? value.trim() : ""
47
+ }
48
+
49
+ function catalogCandidate(record) {
50
+ if (
51
+ !record ||
52
+ typeof record !== "object" ||
53
+ Array.isArray(record) ||
54
+ typeof record.name !== "string"
55
+ ) {
56
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_METADATA_INVALID") }
57
+ }
58
+ if (!TEMPLATE_NAME_RE.test(record.name)) return { unrelated: true }
59
+ if (typeof record.path !== "string" || typeof record.enabled !== "boolean") {
60
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_METADATA_INVALID") }
61
+ }
62
+ if (record.enabled !== true) return { disabled: true }
63
+ // skills/list is authoritative. Do not reinterpret, normalize, or derive this SKILL.md path.
64
+ if (!path.isAbsolute(record.path)) {
65
+ return { error: diagnostic("ARTIFACT_TEMPLATE_SKILL_PATH_INVALID", { skillName: record.name }) }
66
+ }
67
+ const description = optionalTrimmed(record.description)
68
+ || optionalTrimmed(record.interface?.shortDescription)
69
+ || optionalTrimmed(record.interface?.displayName)
70
+ || record.name
71
+ return {
72
+ candidate: {
73
+ skillName: record.name,
74
+ skillPath: record.path,
75
+ description: codepointPrefix(description, ARTIFACT_TEMPLATE_DESCRIPTION_MAX_CODEPOINTS),
76
+ priority: priorityOf(record.name),
77
+ },
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convert app-server skills/list SkillMetadata records to the Desktop-compatible picker catalog.
83
+ * The caller supplies the exact response records; this module deliberately does not scan plugin
84
+ * caches, personal skill directories, or config.toml to second-guess Codex enablement.
85
+ */
86
+ export function collectCodexArtifactTemplateCatalog({ skills = [], platform = process.platform } = {}) {
87
+ const diagnostics = []
88
+ if (!Array.isArray(skills)) {
89
+ return {
90
+ catalog: [],
91
+ diagnostics: [diagnostic("ARTIFACT_TEMPLATE_SKILLS_LIST_INVALID")],
92
+ }
93
+ }
94
+
95
+ const rawCandidates = []
96
+ for (const record of skills) {
97
+ const result = catalogCandidate(record)
98
+ if (result.error) diagnostics.push(result.error)
99
+ else if (result.candidate) rawCandidates.push(result.candidate)
100
+ }
101
+ rawCandidates.sort((a, b) => b.priority - a.priority)
102
+
103
+ const catalog = []
104
+ let catalogBytes = 2 // JSON array brackets: []
105
+ const names = new Set()
106
+ const paths = new Set()
107
+ for (const { priority: _priority, ...candidate } of rawCandidates) {
108
+ const pathKey = normalizedPathKey(candidate.skillPath, platform)
109
+ if (names.has(candidate.skillName) || paths.has(pathKey)) {
110
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_SKILL_DUPLICATE", {
111
+ skillName: candidate.skillName,
112
+ skillPath: candidate.skillPath,
113
+ }))
114
+ continue
115
+ }
116
+ if (catalog.length >= ARTIFACT_TEMPLATE_CATALOG_MAX_ITEMS) {
117
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_CATALOG_ITEM_LIMIT", { skillName: candidate.skillName }))
118
+ continue
119
+ }
120
+ const candidateBytes = Buffer.byteLength(JSON.stringify(candidate), "utf8")
121
+ const nextBytes = catalogBytes + (catalog.length > 0 ? 1 : 0) + candidateBytes
122
+ if (nextBytes > ARTIFACT_TEMPLATE_CATALOG_MAX_BYTES) {
123
+ diagnostics.push(diagnostic("ARTIFACT_TEMPLATE_CATALOG_BYTE_LIMIT", { skillName: candidate.skillName }))
124
+ continue
125
+ }
126
+ catalog.push(candidate)
127
+ catalogBytes = nextBytes
128
+ names.add(candidate.skillName)
129
+ paths.add(pathKey)
130
+ }
131
+ return { catalog, diagnostics }
132
+ }
133
+
134
+ async function isFile(candidate, lstatImpl) {
135
+ try {
136
+ const value = await lstatImpl(candidate)
137
+ return value.isFile() && !value.isSymbolicLink()
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ async function resolveResourcesPath({ env, platform, lstatImpl }) {
144
+ const explicit = env.CODEX_ELECTRON_RESOURCES_PATH
145
+ if (explicit) {
146
+ if (!path.isAbsolute(explicit)) {
147
+ fail("CODEX_ARTIFACT_RESOURCES_PATH_INVALID", "CODEX_ELECTRON_RESOURCES_PATH must be absolute")
148
+ }
149
+ return path.normalize(explicit)
150
+ }
151
+ if (platform === "darwin") {
152
+ const server = path.join(DARWIN_RESOURCES_PATH, "artifact-template-picker", "server.mjs")
153
+ if (await isFile(server, lstatImpl)) return DARWIN_RESOURCES_PATH
154
+ }
155
+ fail("CODEX_ARTIFACT_RESOURCES_PATH_UNAVAILABLE", "Codex Electron resources path is unavailable")
156
+ }
157
+
158
+ export async function preflightCodexArtifactTemplatePicker({
159
+ env = process.env,
160
+ platform = process.platform,
161
+ lstatImpl = lstat,
162
+ } = {}) {
163
+ const resourcesPath = await resolveResourcesPath({ env, platform, lstatImpl })
164
+ const serverPath = path.join(resourcesPath, "artifact-template-picker", "server.mjs")
165
+ if (!await isFile(serverPath, lstatImpl)) {
166
+ fail("CODEX_ARTIFACT_TEMPLATE_PICKER_MISSING", "Codex artifact template picker server is missing")
167
+ }
168
+ return { resourcesPath, serverPath }
169
+ }
170
+
171
+ /** Return the dotted thread/start config shape consumed by Codex app-server. */
172
+ export async function buildCodexArtifactTemplatePickerConfig({
173
+ env = process.env,
174
+ platform = process.platform,
175
+ skills = [],
176
+ resolveWorkspaceDependencies = resolveCodexWorkspaceDependencies,
177
+ lstatImpl = lstat,
178
+ preflight,
179
+ onDiagnostic,
180
+ } = {}) {
181
+ const verified = preflight || await preflightCodexArtifactTemplatePicker({ env, platform, lstatImpl })
182
+ const { serverPath } = verified
183
+ const runtime = await resolveWorkspaceDependencies()
184
+ if (typeof runtime?.nodePath !== "string" || !path.isAbsolute(runtime.nodePath)) {
185
+ fail("CODEX_ARTIFACT_RUNTIME_NODE_INVALID", "Codex primary runtime did not return an absolute nodePath")
186
+ }
187
+ const { catalog, diagnostics } = collectCodexArtifactTemplateCatalog({ skills, platform })
188
+ for (const item of diagnostics) onDiagnostic?.(item)
189
+ return {
190
+ [PICKER_CONFIG_KEY]: {
191
+ command: runtime.nodePath,
192
+ args: [serverPath],
193
+ env: {
194
+ CODEX_ARTIFACT_TEMPLATE_SKILLS: JSON.stringify(catalog),
195
+ },
196
+ },
197
+ }
198
+ }
@@ -0,0 +1,191 @@
1
+ import path from "node:path"
2
+
3
+ import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
4
+
5
+ const NAMESPACE = "codex_app"
6
+ const LOAD_WORKSPACE_DEPENDENCIES = "load_workspace_dependencies"
7
+ const COMPLETED_CALL_LEDGER_MAX = 256
8
+
9
+ const EMPTY_OBJECT_SCHEMA = Object.freeze({
10
+ type: "object",
11
+ properties: {},
12
+ additionalProperties: false,
13
+ })
14
+
15
+ export const CODEX_DYNAMIC_TOOLS = Object.freeze([
16
+ Object.freeze({
17
+ type: "namespace",
18
+ name: NAMESPACE,
19
+ description: "Codex standard runtime tools provided by the Cockpit host.",
20
+ tools: Object.freeze([
21
+ Object.freeze({
22
+ type: "function",
23
+ name: LOAD_WORKSPACE_DEPENDENCIES,
24
+ description: "Resolve the verified Codex primary runtime dependencies for artifact skills.",
25
+ inputSchema: EMPTY_OBJECT_SCHEMA,
26
+ }),
27
+ ]),
28
+ }),
29
+ ])
30
+
31
+ function inputText(text) {
32
+ return { contentItems: [{ type: "inputText", text }], success: true }
33
+ }
34
+
35
+ const REQUIRED_INVENTORY_FIELDS = Object.freeze([
36
+ ["bundleVersion", "Bundle version", false],
37
+ ["gitPath", "Git executable", true],
38
+ ["nodePath", "Node.js executable", false],
39
+ ["nodeModulesPath", "Node.js packages", false],
40
+ ["pnpmPath", "pnpm executable", true],
41
+ ["pythonPath", "Python executable", false],
42
+ ["pythonLibrariesPath", "Python packages", false],
43
+ ["overrideBinPath", "Override binaries", false],
44
+ ["fallbackBinPath", "Fallback binaries", false],
45
+ ])
46
+
47
+ function invalidInventory() {
48
+ const error = new Error("Codex workspace dependency inventory is invalid")
49
+ error.code = "CODEX_DYNAMIC_TOOL_INVENTORY_INVALID"
50
+ throw error
51
+ }
52
+
53
+ function inventoryValue(inventory, field, optional) {
54
+ const value = inventory[field]
55
+ if (optional && (value === null || value === undefined)) return null
56
+ if (!isNonEmptyString(value) || /[\r\n`]/.test(value)) invalidInventory()
57
+ if (field !== "bundleVersion" && !path.isAbsolute(value)) invalidInventory()
58
+ return value
59
+ }
60
+
61
+ function inlineCode(value) {
62
+ return `\`${value}\``
63
+ }
64
+
65
+ /** Format only the canonical resolver inventory; compatibility aliases are never substituted. */
66
+ export function formatCodexWorkspaceDependencies(inventory) {
67
+ if (!inventory || typeof inventory !== "object" || Array.isArray(inventory)) invalidInventory()
68
+ const lines = [
69
+ "Workspace dependencies are available for this local desktop thread.",
70
+ "",
71
+ "### Workspace Dependencies",
72
+ "",
73
+ "Use these bundled paths for sheets, slides, documents, PDFs, images, or browser automation:",
74
+ "",
75
+ ]
76
+ for (const [field, label, optional] of REQUIRED_INVENTORY_FIELDS) {
77
+ const value = inventoryValue(inventory, field, optional)
78
+ if (value !== null) lines.push(`- ${label}: ${inlineCode(value)}`)
79
+ }
80
+ return lines.join("\n")
81
+ }
82
+
83
+ function failure(text) {
84
+ return { contentItems: [{ type: "inputText", text }], success: false }
85
+ }
86
+
87
+ export function codexDynamicToolFailure(code) {
88
+ const safeCode = sanitizeCodexDynamicErrorCode(code)
89
+ return failure(`Dynamic tool call failed (${safeCode})`)
90
+ }
91
+
92
+ export function sanitizeCodexDynamicErrorCode(value, fallback = "CODEX_DYNAMIC_TOOL_ERROR") {
93
+ return typeof value === "string" && value.length <= 128 && /^[A-Z0-9_]+$/.test(value)
94
+ ? value
95
+ : fallback
96
+ }
97
+
98
+ export function sanitizeCodexDynamicToolName(value) {
99
+ return typeof value === "string"
100
+ && value.length >= 1
101
+ && value.length <= 128
102
+ && /^[A-Za-z0-9_.:-]+$/.test(value)
103
+ ? value
104
+ : "unknown"
105
+ }
106
+
107
+ function isNonEmptyString(value) {
108
+ return typeof value === "string" && value.trim().length > 0
109
+ }
110
+
111
+ function validateCall(params) {
112
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
113
+ return { ok: false, code: "INVALID_CALL", tool: "unknown" }
114
+ }
115
+ const tool = sanitizeCodexDynamicToolName(params.tool)
116
+ if (
117
+ !isNonEmptyString(params.threadId) ||
118
+ !isNonEmptyString(params.callId) ||
119
+ !isNonEmptyString(params.turnId)
120
+ ) {
121
+ return { ok: false, code: "INVALID_CALL", tool }
122
+ }
123
+ if (params.namespace !== NAMESPACE) {
124
+ return { ok: false, code: "UNKNOWN_NAMESPACE", tool }
125
+ }
126
+ if (params.tool !== LOAD_WORKSPACE_DEPENDENCIES) {
127
+ return { ok: false, code: "UNKNOWN_TOOL", tool }
128
+ }
129
+ if (
130
+ !params.arguments ||
131
+ typeof params.arguments !== "object" ||
132
+ Array.isArray(params.arguments) ||
133
+ Object.keys(params.arguments).length !== 0
134
+ ) {
135
+ return { ok: false, code: "INVALID_ARGUMENTS", tool }
136
+ }
137
+ return { ok: true, tool }
138
+ }
139
+
140
+ /**
141
+ * A per-session executor for host-provided Codex dynamic tools.
142
+ * Recent results are retained in a bounded replay window so transport retries cannot execute a
143
+ * resolver twice without allowing an unbounded session-lifetime ledger.
144
+ */
145
+ export function createCodexDynamicToolExecutor({
146
+ resolveWorkspaceDependencies = resolveCodexWorkspaceDependencies,
147
+ logger,
148
+ } = {}) {
149
+ const calls = new Map()
150
+
151
+ const executeOnce = async (params) => {
152
+ const validation = validateCall(params)
153
+ if (!validation.ok) {
154
+ logger?.warn?.(
155
+ { tool: validation.tool, error_code: validation.code },
156
+ "codex dynamic tool call rejected",
157
+ )
158
+ return failure(`Dynamic tool call rejected (${validation.code})`)
159
+ }
160
+
161
+ try {
162
+ const dependencies = await resolveWorkspaceDependencies()
163
+ return inputText(formatCodexWorkspaceDependencies(dependencies))
164
+ } catch (error) {
165
+ const code = sanitizeCodexDynamicErrorCode(error?.code)
166
+ logger?.warn?.(
167
+ { tool: validation.tool, error_code: code },
168
+ "codex dynamic tool call failed",
169
+ )
170
+ return failure(`Workspace dependency resolution failed (${code})`)
171
+ }
172
+ }
173
+
174
+ return {
175
+ definitions: CODEX_DYNAMIC_TOOLS,
176
+ execute(params) {
177
+ const threadId = isNonEmptyString(params?.threadId) ? params.threadId : null
178
+ const callId = isNonEmptyString(params?.callId) ? params.callId : null
179
+ if (!threadId || !callId) return executeOnce(params)
180
+ const key = JSON.stringify([threadId, callId])
181
+ const existing = calls.get(key)
182
+ if (existing) return existing
183
+ const pending = executeOnce(params)
184
+ calls.set(key, pending)
185
+ if (calls.size > COMPLETED_CALL_LEDGER_MAX) {
186
+ calls.delete(calls.keys().next().value)
187
+ }
188
+ return pending
189
+ },
190
+ }
191
+ }
@@ -47,6 +47,17 @@ import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
47
47
 
48
48
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
49
49
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
50
+ import {
51
+ buildCodexArtifactTemplatePickerConfig,
52
+ preflightCodexArtifactTemplatePicker,
53
+ } from "./codex-artifact-template-picker.mjs"
54
+ import {
55
+ codexDynamicToolFailure,
56
+ createCodexDynamicToolExecutor,
57
+ sanitizeCodexDynamicErrorCode,
58
+ sanitizeCodexDynamicToolName,
59
+ } from "./codex-dynamic-tools.mjs"
60
+ import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
50
61
  import {
51
62
  snapshotCodexDeliveryAudit,
52
63
  verifyCodexDeliveryAudit,
@@ -61,6 +72,120 @@ function parseTurnMismatch(reason) {
61
72
  return mismatch ? { expected: mismatch[1], found: mismatch[2] } : null
62
73
  }
63
74
 
75
+ function codexHostCapabilityErrorCode(error, fallback) {
76
+ return sanitizeCodexDynamicErrorCode(error?.code, fallback)
77
+ }
78
+
79
+ function flattenCodexSkillsList(result) {
80
+ if (!result || !Array.isArray(result.data)) {
81
+ const error = new Error("invalid skills/list response")
82
+ error.code = "CODEX_SKILLS_LIST_RESPONSE_INVALID"
83
+ throw error
84
+ }
85
+ const skills = []
86
+ for (const entry of result.data) {
87
+ if (!entry || !Array.isArray(entry.skills)) {
88
+ const error = new Error("invalid skills/list response")
89
+ error.code = "CODEX_SKILLS_LIST_RESPONSE_INVALID"
90
+ throw error
91
+ }
92
+ // SkillMetadata は Codex が正本。未知 field を選別・再構築せず、そのまま picker へ渡す。
93
+ skills.push(...entry.skills)
94
+ }
95
+ return skills
96
+ }
97
+
98
+ function deadlineError(code) {
99
+ const error = new Error(code)
100
+ error.code = code
101
+ return error
102
+ }
103
+
104
+ async function withDeadline(promise, timeoutMs, code) {
105
+ let timer
106
+ const timeout = new Promise((_, reject) => {
107
+ timer = setTimeout(() => reject(deadlineError(code)), timeoutMs)
108
+ timer.unref?.()
109
+ })
110
+ try {
111
+ return await Promise.race([Promise.resolve(promise), timeout])
112
+ } finally {
113
+ clearTimeout(timer)
114
+ }
115
+ }
116
+
117
+ async function buildCodexHostCapabilities({
118
+ client,
119
+ cwd,
120
+ artifactTemplatePickerConfigBuilder,
121
+ artifactTemplatePickerPreflight,
122
+ resolveWorkspaceDependencies,
123
+ }) {
124
+ let preflight
125
+ try {
126
+ preflight = await artifactTemplatePickerPreflight()
127
+ } catch (error) {
128
+ return {
129
+ config: null,
130
+ warning: {
131
+ error_code: codexHostCapabilityErrorCode(
132
+ error,
133
+ "CODEX_ARTIFACT_TEMPLATE_PICKER_PREFLIGHT_FAILED",
134
+ ),
135
+ skill_count: 0,
136
+ },
137
+ }
138
+ }
139
+ let skills
140
+ try {
141
+ skills = flattenCodexSkillsList(await client.request("skills/list", { cwds: [cwd] }))
142
+ } catch (error) {
143
+ return {
144
+ config: null,
145
+ warning: {
146
+ error_code: codexHostCapabilityErrorCode(error, "CODEX_SKILLS_LIST_FAILED"),
147
+ skill_count: 0,
148
+ },
149
+ }
150
+ }
151
+
152
+ const diagnosticCounts = new Map()
153
+ try {
154
+ const config = await artifactTemplatePickerConfigBuilder({
155
+ skills,
156
+ preflight,
157
+ resolveWorkspaceDependencies,
158
+ onDiagnostic: (item) => {
159
+ const code = codexHostCapabilityErrorCode(
160
+ item,
161
+ "CODEX_ARTIFACT_TEMPLATE_PICKER_DIAGNOSTIC",
162
+ )
163
+ diagnosticCounts.set(code, (diagnosticCounts.get(code) || 0) + 1)
164
+ },
165
+ })
166
+ return {
167
+ config: config && typeof config === "object" && !Array.isArray(config) ? config : null,
168
+ warning: diagnosticCounts.size > 0
169
+ ? { diagnostic_counts: Object.fromEntries(diagnosticCounts), skill_count: skills.length }
170
+ : null,
171
+ }
172
+ } catch (error) {
173
+ return {
174
+ config: null,
175
+ warning: {
176
+ error_code: codexHostCapabilityErrorCode(
177
+ error,
178
+ "CODEX_ARTIFACT_TEMPLATE_PICKER_CONFIG_FAILED",
179
+ ),
180
+ ...(diagnosticCounts.size > 0
181
+ ? { diagnostic_counts: Object.fromEntries(diagnosticCounts) }
182
+ : {}),
183
+ skill_count: skills.length,
184
+ },
185
+ }
186
+ }
187
+ }
188
+
64
189
  /**
65
190
  * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
66
191
  * 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
@@ -209,6 +334,24 @@ const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
209
334
  )
210
335
 
211
336
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
337
+ const WORKSPACE_DEPENDENCIES_CACHE_TTL_MS = 30_000
338
+ const WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS = readPositiveIntEnv(
339
+ "HUB_AGENT_CODEX_WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS",
340
+ 30_000,
341
+ )
342
+ const WORKSPACE_DEPENDENCIES_DEADLINE_MS = readPositiveIntEnv(
343
+ "HUB_AGENT_CODEX_WORKSPACE_DEPENDENCIES_DEADLINE_MS",
344
+ 45_000,
345
+ )
346
+ const HOST_CAPABILITIES_DEADLINE_MS = readPositiveIntEnv(
347
+ "HUB_AGENT_CODEX_HOST_CAPABILITIES_DEADLINE_MS",
348
+ 10_000,
349
+ )
350
+ const FORM_ELICITATION_TIMEOUT_MS = readPositiveIntEnv(
351
+ "HUB_AGENT_CODEX_FORM_ELICITATION_TIMEOUT_MS",
352
+ 10 * 60_000,
353
+ )
354
+ const DYNAMIC_TOOL_REQUEST_LEDGER_MAX = 256
212
355
 
213
356
  // T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
214
357
  // 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
@@ -466,11 +609,80 @@ export class CodexStreamBridge extends EventEmitter {
466
609
  historyMaxBytes,
467
610
  listThreadsCacheTtlMs,
468
611
  accountRateLimitsCacheTtlMs,
612
+ workspaceDependenciesCacheTtlMs,
613
+ workspaceDependenciesFailureTtlMs,
614
+ workspaceDependenciesDeadlineMs,
615
+ hostCapabilitiesDeadlineMs,
616
+ formElicitationTimeoutMs,
617
+ resolveWorkspaceDependencies,
618
+ dynamicToolExecutorFactory,
619
+ artifactTemplatePickerConfigBuilder,
620
+ artifactTemplatePickerPreflight,
469
621
  } = {}) {
470
622
  super()
471
623
  this.codexCmd = codexCmd
472
624
  this.logger = logger
473
625
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
626
+ const uncachedWorkspaceDependencies = resolveWorkspaceDependencies || resolveCodexWorkspaceDependencies
627
+ this._workspaceDependenciesCacheTtlMs = sanitizePositiveInt(
628
+ workspaceDependenciesCacheTtlMs,
629
+ WORKSPACE_DEPENDENCIES_CACHE_TTL_MS,
630
+ { logger: this.logger, label: "codex.workspaceDependenciesCacheTtlMs" },
631
+ )
632
+ this._workspaceDependenciesFailureTtlMs = sanitizePositiveInt(
633
+ workspaceDependenciesFailureTtlMs,
634
+ WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS,
635
+ { logger: this.logger, label: "codex.workspaceDependenciesFailureTtlMs" },
636
+ )
637
+ this._workspaceDependenciesDeadlineMs = sanitizePositiveInt(
638
+ workspaceDependenciesDeadlineMs,
639
+ WORKSPACE_DEPENDENCIES_DEADLINE_MS,
640
+ { logger: this.logger, label: "codex.workspaceDependenciesDeadlineMs" },
641
+ )
642
+ this._hostCapabilitiesDeadlineMs = sanitizePositiveInt(
643
+ hostCapabilitiesDeadlineMs,
644
+ HOST_CAPABILITIES_DEADLINE_MS,
645
+ { logger: this.logger, label: "codex.hostCapabilitiesDeadlineMs" },
646
+ )
647
+ this._formElicitationTimeoutMs = sanitizePositiveInt(
648
+ formElicitationTimeoutMs,
649
+ FORM_ELICITATION_TIMEOUT_MS,
650
+ { logger: this.logger, label: "codex.formElicitationTimeoutMs" },
651
+ )
652
+ this._workspaceDependenciesCache = new AsyncTtlCache({
653
+ ttlMs: this._workspaceDependenciesCacheTtlMs,
654
+ })
655
+ this._workspaceDependenciesFailure = null
656
+ this._resolveWorkspaceDependencies = async () => {
657
+ const cachedFailure = this._workspaceDependenciesFailure
658
+ if (cachedFailure && cachedFailure.expiresAt > Date.now()) {
659
+ throw deadlineError(cachedFailure.code)
660
+ }
661
+ this._workspaceDependenciesFailure = null
662
+ try {
663
+ const dependencies = await this._workspaceDependenciesCache.get(
664
+ () => withDeadline(
665
+ Promise.resolve().then(uncachedWorkspaceDependencies),
666
+ this._workspaceDependenciesDeadlineMs,
667
+ "CODEX_WORKSPACE_DEPENDENCIES_TIMEOUT",
668
+ ),
669
+ )
670
+ this._workspaceDependenciesFailure = null
671
+ return dependencies
672
+ } catch (error) {
673
+ // Negative cacheには安全なcodeと期限だけを保持する。cause/messageは秘密を含み得る。
674
+ this._workspaceDependenciesFailure = {
675
+ code: codexHostCapabilityErrorCode(error, "CODEX_WORKSPACE_DEPENDENCIES_FAILED"),
676
+ expiresAt: Date.now() + this._workspaceDependenciesFailureTtlMs,
677
+ }
678
+ throw deadlineError(this._workspaceDependenciesFailure.code)
679
+ }
680
+ }
681
+ this._dynamicToolExecutorFactory = dynamicToolExecutorFactory || createCodexDynamicToolExecutor
682
+ this._artifactTemplatePickerConfigBuilder = artifactTemplatePickerConfigBuilder
683
+ || buildCodexArtifactTemplatePickerConfig
684
+ this._artifactTemplatePickerPreflight = artifactTemplatePickerPreflight
685
+ || preflightCodexArtifactTemplatePicker
474
686
  // Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
475
687
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
476
688
  // 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
@@ -741,6 +953,14 @@ export class CodexStreamBridge extends EventEmitter {
741
953
  // request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
742
954
  // 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
743
955
  pendingApprovals: new Map(),
956
+ // host dynamic tool は permission request と別経路で即時応答する。
957
+ dynamicToolExecutor: this._dynamicToolExecutorFactory({
958
+ resolveWorkspaceDependencies: this._resolveWorkspaceDependencies,
959
+ logger: this.logger,
960
+ }),
961
+ // JSON-RPC request id ごとの exactly-once 応答台帳。callId の resolver single-flight は
962
+ // executor が別に担い、同じ callId でも request id が違えば各 request へ応答する。
963
+ handledDynamicToolRequestIds: new Set(),
744
964
  // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
745
965
  // Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
746
966
  // (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
@@ -827,7 +1047,13 @@ export class CodexStreamBridge extends EventEmitter {
827
1047
  { stream_id, cwd, resuming, lifecycle_stage: "initialize_started" },
828
1048
  "codex app-server session initialization",
829
1049
  )
830
- await client.request("initialize", { clientInfo: CLIENT_INFO })
1050
+ await client.request("initialize", {
1051
+ clientInfo: CLIENT_INFO,
1052
+ capabilities: {
1053
+ experimentalApi: true,
1054
+ mcpServerOpenaiFormElicitation: true,
1055
+ },
1056
+ })
831
1057
  // ここまでが「app-server プロセスが起動して JSON-RPC に応答できるようになるまで」。
832
1058
  timing.initialize_ms = lap()
833
1059
  // 公式 App Server handshake: initialize 応答後、同じ transport 上で initialized を
@@ -837,10 +1063,43 @@ export class CodexStreamBridge extends EventEmitter {
837
1063
  { stream_id, cwd, resuming, lifecycle_stage: "initialized_sent" },
838
1064
  "codex app-server session initialization",
839
1065
  )
840
- reachedThreadOp = true
841
1066
  const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
842
1067
  if (effort) startParams.effort = effort
843
1068
  if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
1069
+ // app-serverを跨ぐcold resumeではthreadのhost側MCP/dynamic tool登録が復元されない実測が
1070
+ // あるため、fresh startと同じcapabilityを毎回再注入する。in-memory reattachはこのcold経路へ
1071
+ // 入らないので、既存process上での再buildは発生しない。
1072
+ try {
1073
+ const hostCapabilities = await withDeadline(
1074
+ buildCodexHostCapabilities({
1075
+ client,
1076
+ cwd,
1077
+ artifactTemplatePickerConfigBuilder: this._artifactTemplatePickerConfigBuilder,
1078
+ artifactTemplatePickerPreflight: this._artifactTemplatePickerPreflight,
1079
+ resolveWorkspaceDependencies: this._resolveWorkspaceDependencies,
1080
+ }),
1081
+ this._hostCapabilitiesDeadlineMs,
1082
+ "CODEX_HOST_CAPABILITIES_TIMEOUT",
1083
+ )
1084
+ if (hostCapabilities.config) startParams.config = { ...hostCapabilities.config }
1085
+ if (hostCapabilities.warning) {
1086
+ this.logger?.warn?.(
1087
+ hostCapabilities.warning,
1088
+ "codex artifact template picker degraded",
1089
+ )
1090
+ }
1091
+ } catch (error) {
1092
+ this.logger?.warn?.(
1093
+ {
1094
+ error_code: codexHostCapabilityErrorCode(error, "CODEX_HOST_CAPABILITIES_FAILED"),
1095
+ skill_count: 0,
1096
+ },
1097
+ "codex artifact template picker degraded",
1098
+ )
1099
+ }
1100
+ startParams.dynamicTools = session.dynamicToolExecutor.definitions
1101
+ startParams.runtimeWorkspaceRoots = [cwd]
1102
+ timing.host_capabilities_ms = lap()
844
1103
  this.logger?.info?.(
845
1104
  {
846
1105
  stream_id,
@@ -851,6 +1110,7 @@ export class CodexStreamBridge extends EventEmitter {
851
1110
  },
852
1111
  "codex app-server session initialization",
853
1112
  )
1113
+ reachedThreadOp = true
854
1114
  const result = resuming
855
1115
  ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
856
1116
  : await client.request("thread/start", startParams)
@@ -951,6 +1211,7 @@ export class CodexStreamBridge extends EventEmitter {
951
1211
  }
952
1212
  } catch (err) {
953
1213
  this.sessions.delete(stream_id)
1214
+ this._settleAllPendingApprovals(session, { respond: false })
954
1215
  client.stop()
955
1216
  settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
956
1217
  // 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
@@ -987,6 +1248,7 @@ export class CodexStreamBridge extends EventEmitter {
987
1248
  * ## 読み方
988
1249
  * - `warm=true` / `mode=reattach_*` … app-server を立てていない (所要は `reattach_wait_ms` だけ)
989
1250
  * - `warm=false` / `mode=cold_*` … `spawn_ms`(同期 spawn) + `initialize_ms`(プロセス起動待ち)
1251
+ * + `host_capabilities_ms`(cold start/resume の skills/runtime 構築)
990
1252
  * + `thread_op_ms`(thread/start|resume。resume は rollout 読み込み込み) + `history_flatten_ms`
991
1253
  *
992
1254
  * ログ量: attach 時のみ (ターン毎には出ない)。既存の "codex session attached" と同オーダー。
@@ -1000,6 +1262,7 @@ export class CodexStreamBridge extends EventEmitter {
1000
1262
  reattach_wait_ms: timing.reattach_wait_ms ?? null,
1001
1263
  spawn_ms: timing.spawn_ms ?? null,
1002
1264
  initialize_ms: timing.initialize_ms ?? null,
1265
+ host_capabilities_ms: timing.host_capabilities_ms ?? null,
1003
1266
  thread_op_ms: timing.thread_op_ms ?? null,
1004
1267
  history_flatten_ms: timing.history_flatten_ms ?? null,
1005
1268
  },
@@ -1249,9 +1512,22 @@ export class CodexStreamBridge extends EventEmitter {
1249
1512
  /** app-server を 1 本 spawn して account/rateLimits/read を取る実処理 (キャッシュ miss 時のみ)。 */
1250
1513
  async _fetchAccountRateLimits({ cwd, profileId }) {
1251
1514
  const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
1515
+ client.on("error", (error) => {
1516
+ this.logger?.warn?.(
1517
+ {
1518
+ error_code: codexHostCapabilityErrorCode(
1519
+ error,
1520
+ "CODEX_ACCOUNT_RATE_LIMITS_CLIENT_ERROR",
1521
+ ),
1522
+ },
1523
+ "codex account rate limits client error",
1524
+ )
1525
+ })
1252
1526
  client.start()
1253
1527
  try {
1254
- await client.request("initialize", { clientInfo: CLIENT_INFO })
1528
+ await client.request("initialize", {
1529
+ clientInfo: CLIENT_INFO,
1530
+ })
1255
1531
  client.notify("initialized", {})
1256
1532
  const result = await client.request("account/rateLimits/read", {})
1257
1533
  this._accountRateLimitsByProfile.set(profileId, result)
@@ -2540,13 +2816,27 @@ export class CodexStreamBridge extends EventEmitter {
2540
2816
  }
2541
2817
 
2542
2818
  _onServerRequest(session, msg) {
2819
+ if (msg?.method === "item/tool/call") {
2820
+ this._onDynamicToolCall(session, msg)
2821
+ return
2822
+ }
2543
2823
  const { stream_id } = session
2824
+ const previous = session.pendingApprovals.get(msg.id)
2825
+ if (previous?.timer) clearTimeout(previous.timer)
2544
2826
  // major#1: reattach 時に新 stream へ再送できるよう method/params も保持する。
2545
- session.pendingApprovals.set(msg.id, {
2827
+ const pending = {
2546
2828
  turnId: session.activeTurnId,
2547
2829
  method: msg.method,
2548
2830
  params: msg.params,
2549
- })
2831
+ timer: null,
2832
+ }
2833
+ if (msg.method === "mcpServer/elicitation/request") {
2834
+ pending.timer = setTimeout(() => {
2835
+ this._expireFormElicitation(session, msg.id, pending)
2836
+ }, this._formElicitationTimeoutMs)
2837
+ pending.timer.unref?.()
2838
+ }
2839
+ session.pendingApprovals.set(msg.id, pending)
2550
2840
  this.emit("permission", {
2551
2841
  stream_id,
2552
2842
  thread_id: session.threadId,
@@ -2557,6 +2847,49 @@ export class CodexStreamBridge extends EventEmitter {
2557
2847
  })
2558
2848
  }
2559
2849
 
2850
+ _onDynamicToolCall(session, msg) {
2851
+ if (session.handledDynamicToolRequestIds.has(msg?.id)) return
2852
+ session.handledDynamicToolRequestIds.add(msg?.id)
2853
+ if (session.handledDynamicToolRequestIds.size > DYNAMIC_TOOL_REQUEST_LEDGER_MAX) {
2854
+ session.handledDynamicToolRequestIds.delete(
2855
+ session.handledDynamicToolRequestIds.values().next().value,
2856
+ )
2857
+ }
2858
+
2859
+ const tool = sanitizeCodexDynamicToolName(msg?.params?.tool)
2860
+ let execution
2861
+ const requestThreadId = msg?.params?.threadId
2862
+ const allowedThread = requestThreadId === session.threadId
2863
+ // 表示台帳と停止台帳は観測経路・寿命が異なる。どちらか一方で既知なら、このsessionが
2864
+ // 所有するsubagent threadとして許可する意図的なOR結合(積集合へ狭めない)。
2865
+ || session.subagents.has(requestThreadId)
2866
+ || session.subagentInterruptRegistry.has(requestThreadId)
2867
+ if (!allowedThread) {
2868
+ this.logger?.warn?.(
2869
+ { tool, error_code: "THREAD_MISMATCH" },
2870
+ "codex dynamic tool call rejected",
2871
+ )
2872
+ execution = Promise.resolve(codexDynamicToolFailure("THREAD_MISMATCH"))
2873
+ } else {
2874
+ execution = Promise.resolve().then(() => session.dynamicToolExecutor.execute(msg?.params))
2875
+ }
2876
+
2877
+ void execution.catch(() => {
2878
+ this.logger?.warn?.(
2879
+ { tool, error_code: "DYNAMIC_TOOL_EXECUTOR_REJECTED" },
2880
+ "codex dynamic tool call failed",
2881
+ )
2882
+ return codexDynamicToolFailure("DYNAMIC_TOOL_EXECUTOR_REJECTED")
2883
+ }).then((result) => {
2884
+ session.client.respond(msg.id, result)
2885
+ }).catch(() => {
2886
+ this.logger?.warn?.(
2887
+ { tool, error_code: "DYNAMIC_TOOL_RESPONSE_FAILED" },
2888
+ "codex dynamic tool response failed",
2889
+ )
2890
+ })
2891
+ }
2892
+
2560
2893
  /**
2561
2894
  * major#1: 未応答の承認要求を現在の stream へ再送する (reattach から呼ぶ)。Claude の
2562
2895
  * _replayPendingPermissions と同型。frontend は request_id で重複排除するため、既に表示済みの
@@ -2576,14 +2909,63 @@ export class CodexStreamBridge extends EventEmitter {
2576
2909
  }
2577
2910
  }
2578
2911
 
2912
+ _pendingApprovalResult(info) {
2913
+ return info?.method === "mcpServer/elicitation/request"
2914
+ ? { action: "cancel", content: null }
2915
+ : { decision: "decline" }
2916
+ }
2917
+
2918
+ _settlePendingApproval(session, requestId, result, { respond = true } = {}) {
2919
+ const info = session?.pendingApprovals?.get(requestId)
2920
+ if (!info) return false
2921
+ session.pendingApprovals.delete(requestId)
2922
+ if (info.timer) clearTimeout(info.timer)
2923
+ if (respond) session.client.respond(requestId, result)
2924
+ return true
2925
+ }
2926
+
2927
+ _settleAllPendingApprovals(session, { respond = true } = {}) {
2928
+ for (const [requestId, info] of session?.pendingApprovals || []) {
2929
+ this._settlePendingApproval(
2930
+ session,
2931
+ requestId,
2932
+ this._pendingApprovalResult(info),
2933
+ { respond },
2934
+ )
2935
+ }
2936
+ }
2937
+
2938
+ _expireFormElicitation(session, requestId, expected) {
2939
+ if (session?.pendingApprovals?.get(requestId) !== expected) return
2940
+ if (!this._settlePendingApproval(
2941
+ session,
2942
+ requestId,
2943
+ { action: "cancel", content: null },
2944
+ )) return
2945
+ this.emit("event", {
2946
+ stream_id: session.stream_id,
2947
+ producer_seq: session.seq,
2948
+ thread_id: session.threadId,
2949
+ cwd: session.cwd,
2950
+ notification: {
2951
+ method: "cockpit/permission_expired",
2952
+ params: { request_id: requestId, method: expected.method },
2953
+ },
2954
+ })
2955
+ }
2956
+
2579
2957
  // ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
2580
2958
  // (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
2581
2959
  // claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
2582
2960
  _autoResolveStalePendingApprovals(session, completedTurnId) {
2583
2961
  for (const [requestId, info] of session.pendingApprovals) {
2584
- if (info.turnId !== completedTurnId) continue
2585
- session.pendingApprovals.delete(requestId)
2586
- session.client.respond(requestId, { decision: "decline" })
2962
+ const belongsToCompletedTurn = info.turnId !== null && info.turnId === completedTurnId
2963
+ if (!belongsToCompletedTurn) continue
2964
+ this._settlePendingApproval(
2965
+ session,
2966
+ requestId,
2967
+ this._pendingApprovalResult(info),
2968
+ )
2587
2969
  }
2588
2970
  }
2589
2971
 
@@ -2595,6 +2977,7 @@ export class CodexStreamBridge extends EventEmitter {
2595
2977
  this._settleTurnActiveWaiter(session)
2596
2978
  this._drainRejectQueue(session, { reason: "queue_dead" })
2597
2979
  this._settlePendingInjectDeliveries(session, "session_dead")
2980
+ this._settleAllPendingApprovals(session, { respond: false })
2598
2981
  // 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
2599
2982
  // 他の購読キーが dangling で残る)。
2600
2983
  this._dropAllStreamKeys(session)
@@ -2947,8 +3330,7 @@ export class CodexStreamBridge extends EventEmitter {
2947
3330
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
2948
3331
  return
2949
3332
  }
2950
- session.pendingApprovals.delete(request_id)
2951
- session.client.respond(request_id, result)
3333
+ this._settlePendingApproval(session, request_id, result)
2952
3334
  }
2953
3335
 
2954
3336
  /**
@@ -3629,6 +4011,7 @@ export class CodexStreamBridge extends EventEmitter {
3629
4011
  this._settleTurnActiveWaiter(session)
3630
4012
  this._drainRejectQueue(session, { reason: "queue_dead" })
3631
4013
  this._settlePendingInjectDeliveries(session, "session_dead")
4014
+ this._settleAllPendingApprovals(session)
3632
4015
  // 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
3633
4016
  this._dropAllStreamKeys(session)
3634
4017
  try {
@@ -3817,6 +4200,7 @@ export class CodexStreamBridge extends EventEmitter {
3817
4200
  this._settleTurnActiveWaiter(session)
3818
4201
  this._drainRejectQueue(session, { reason: "queue_dead" })
3819
4202
  this._settlePendingInjectDeliveries(session, "session_dead")
4203
+ this._settleAllPendingApprovals(session)
3820
4204
  // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
3821
4205
  // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
3822
4206
  this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
@@ -0,0 +1,331 @@
1
+ import { constants as fsConstants } from "node:fs"
2
+ import { access, readFile, realpath, stat } from "node:fs/promises"
3
+ import { execFile } from "node:child_process"
4
+ import { promisify } from "node:util"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+
8
+ export const CODEX_PRIMARY_RUNTIME_BUNDLE_FORMAT = 2
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ export class CodexWorkspaceDependenciesError extends Error {
12
+ constructor(code, message, options = {}) {
13
+ super(message, options)
14
+ this.name = "CodexWorkspaceDependenciesError"
15
+ this.code = code
16
+ }
17
+ }
18
+
19
+ function fail(code, message, cause) {
20
+ throw new CodexWorkspaceDependenciesError(code, message, cause ? { cause } : undefined)
21
+ }
22
+
23
+ function resolveRuntimeRoot(runtimeRootOverride, homedirImpl) {
24
+ if (runtimeRootOverride !== undefined) {
25
+ if (typeof runtimeRootOverride !== "string" || !path.isAbsolute(runtimeRootOverride)) {
26
+ fail("CODEX_RUNTIME_ROOT_INVALID", "runtimeRootOverride must be an absolute path")
27
+ }
28
+ return runtimeRootOverride
29
+ }
30
+
31
+ const home = homedirImpl()
32
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
33
+ fail("CODEX_RUNTIME_ROOT_INVALID", "OS home directory must be an absolute path")
34
+ }
35
+ return path.join(home, ".cache", "codex-runtimes", "codex-primary-runtime")
36
+ }
37
+
38
+ function requireNonemptyString(manifest, field, { optional = false } = {}) {
39
+ const value = manifest[field]
40
+ if (optional && value === undefined) return null
41
+ if (typeof value !== "string" || value.trim() === "") {
42
+ fail("CODEX_RUNTIME_MANIFEST_INVALID", `Codex primary runtime manifest field ${field} must be a nonempty string`)
43
+ }
44
+ return value
45
+ }
46
+
47
+ function requireNativeDependencies(manifest) {
48
+ if (manifest.nativeDependencies === undefined) return []
49
+ if (
50
+ !Array.isArray(manifest.nativeDependencies)
51
+ || manifest.nativeDependencies.some((item) => typeof item !== "string" || item.trim() === "")
52
+ ) {
53
+ fail(
54
+ "CODEX_RUNTIME_MANIFEST_INVALID",
55
+ "Codex primary runtime manifest field nativeDependencies must be an array of nonempty strings",
56
+ )
57
+ }
58
+ return manifest.nativeDependencies
59
+ }
60
+
61
+ async function readManifest(manifestPath, readFileImpl) {
62
+ let raw
63
+ try {
64
+ raw = await readFileImpl(manifestPath, "utf8")
65
+ } catch (error) {
66
+ const code = error?.code === "ENOENT"
67
+ ? "CODEX_RUNTIME_MANIFEST_MISSING"
68
+ : "CODEX_RUNTIME_MANIFEST_UNREADABLE"
69
+ fail(code, "Codex primary runtime manifest is not readable", error)
70
+ }
71
+
72
+ try {
73
+ const manifest = JSON.parse(raw)
74
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
75
+ fail("CODEX_RUNTIME_MANIFEST_INVALID", "Codex primary runtime manifest must be a JSON object")
76
+ }
77
+ return manifest
78
+ } catch (error) {
79
+ if (error instanceof CodexWorkspaceDependenciesError) throw error
80
+ fail("CODEX_RUNTIME_MANIFEST_INVALID_JSON", "Codex primary runtime manifest is not valid JSON", error)
81
+ }
82
+ }
83
+
84
+ async function requireFile(candidate, label, statImpl) {
85
+ try {
86
+ if ((await statImpl(candidate)).isFile()) return
87
+ } catch (error) {
88
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
89
+ }
90
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} must be a file`)
91
+ }
92
+
93
+ async function requireDirectory(candidate, label, statImpl) {
94
+ try {
95
+ if ((await statImpl(candidate)).isDirectory()) return
96
+ } catch (error) {
97
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
98
+ }
99
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} must be a directory`)
100
+ }
101
+
102
+ async function requireExecutableFile(candidate, label, executableErrorCode, statImpl, accessImpl) {
103
+ await requireFile(candidate, label, statImpl)
104
+ try {
105
+ await accessImpl(candidate, fsConstants.X_OK)
106
+ } catch (error) {
107
+ fail(executableErrorCode, `${label} is not executable`, error)
108
+ }
109
+ }
110
+
111
+ function isWithinRoot(candidate, root) {
112
+ const relative = path.relative(root, candidate)
113
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
114
+ }
115
+
116
+ async function requireContainedRealPath(candidate, label, realRuntimeRoot, realpathImpl) {
117
+ let resolved
118
+ try {
119
+ resolved = await realpathImpl(candidate)
120
+ } catch (error) {
121
+ fail("CODEX_RUNTIME_PATH_MISSING", `${label} is missing or inaccessible`, error)
122
+ }
123
+ if (typeof resolved !== "string" || !path.isAbsolute(resolved)) {
124
+ fail("CODEX_RUNTIME_PATH_INVALID", `${label} real path must be absolute`)
125
+ }
126
+ if (!isWithinRoot(resolved, realRuntimeRoot)) {
127
+ fail("CODEX_RUNTIME_PATH_OUTSIDE_ROOT", `${label} resolves outside the Codex primary runtime root`)
128
+ }
129
+ return resolved
130
+ }
131
+
132
+ async function requireContainedManifestPath(manifestPath, realRuntimeRoot, realpathImpl) {
133
+ let resolved
134
+ try {
135
+ resolved = await realpathImpl(manifestPath)
136
+ } catch (error) {
137
+ const code = error?.code === "ENOENT"
138
+ ? "CODEX_RUNTIME_MANIFEST_MISSING"
139
+ : "CODEX_RUNTIME_MANIFEST_UNREADABLE"
140
+ fail(code, "Codex primary runtime manifest is not accessible", error)
141
+ }
142
+ if (typeof resolved !== "string" || !path.isAbsolute(resolved)) {
143
+ fail("CODEX_RUNTIME_PATH_INVALID", "Codex primary runtime manifest real path must be absolute")
144
+ }
145
+ if (!isWithinRoot(resolved, realRuntimeRoot)) {
146
+ fail(
147
+ "CODEX_RUNTIME_PATH_OUTSIDE_ROOT",
148
+ "Codex primary runtime manifest resolves outside the Codex primary runtime root",
149
+ )
150
+ }
151
+ return resolved
152
+ }
153
+
154
+ function childEnvWithoutExternalLanguagePaths(env) {
155
+ const childEnv = { ...env }
156
+ delete childEnv.NODE_OPTIONS
157
+ delete childEnv.NODE_PATH
158
+ delete childEnv.PYTHONHOME
159
+ delete childEnv.PYTHONPATH
160
+ return childEnv
161
+ }
162
+
163
+ /**
164
+ * Codex standard primary runtime の絶対パスを検証して返す。
165
+ *
166
+ * runtime.json 以外から版や配置を推測せず、ローカル npm・system Node・PATH へ
167
+ * fallback しない。runtimeRoot 自体のsymlinkは追従し、そのrealpathをcontainment rootとする。
168
+ * host側は成功・失敗をそれぞれ短期cacheするが、cache miss時はここで実体を再検証する。
169
+ */
170
+ export async function resolveCodexWorkspaceDependencies({
171
+ env = process.env,
172
+ platform = process.platform,
173
+ arch = process.arch,
174
+ runtimeRootOverride,
175
+ homedirImpl = os.homedir,
176
+ readFileImpl = readFile,
177
+ realpathImpl = realpath,
178
+ statImpl = stat,
179
+ accessImpl = access,
180
+ execFileImpl = execFileAsync,
181
+ } = {}) {
182
+ const runtimeRoot = resolveRuntimeRoot(runtimeRootOverride, homedirImpl)
183
+ const manifestPath = path.join(runtimeRoot, "runtime.json")
184
+ let realRuntimeRoot
185
+ try {
186
+ realRuntimeRoot = await realpathImpl(runtimeRoot)
187
+ } catch (error) {
188
+ fail("CODEX_RUNTIME_ROOT_UNREADABLE", "Codex primary runtime root is not accessible", error)
189
+ }
190
+ if (!path.isAbsolute(realRuntimeRoot)) {
191
+ fail("CODEX_RUNTIME_ROOT_INVALID", "Codex primary runtime real path must be absolute")
192
+ }
193
+ // manifestの内容を読む前に、symlinkを含む実体がruntime root内にあることを確認する。
194
+ await requireContainedManifestPath(manifestPath, realRuntimeRoot, realpathImpl)
195
+ const manifest = await readManifest(manifestPath, readFileImpl)
196
+
197
+ if (manifest.bundleFormatVersion !== CODEX_PRIMARY_RUNTIME_BUNDLE_FORMAT) {
198
+ fail(
199
+ "CODEX_RUNTIME_BUNDLE_FORMAT_UNSUPPORTED",
200
+ `Unsupported Codex primary runtime bundle format: ${String(manifest.bundleFormatVersion)}`,
201
+ )
202
+ }
203
+ if (manifest.targetPlatform !== platform) {
204
+ fail("CODEX_RUNTIME_PLATFORM_MISMATCH", "Codex primary runtime target platform does not match this process")
205
+ }
206
+ if (manifest.targetArch !== arch) {
207
+ fail("CODEX_RUNTIME_ARCH_MISMATCH", "Codex primary runtime target architecture does not match this process")
208
+ }
209
+ if (platform === "win32") {
210
+ fail(
211
+ "CODEX_RUNTIME_PLATFORM_UNSUPPORTED",
212
+ "Codex primary runtime layout for Windows is not supported by this resolver",
213
+ )
214
+ }
215
+
216
+ const bundleVersion = requireNonemptyString(manifest, "bundleVersion")
217
+ requireNonemptyString(manifest, "nodeVersion")
218
+ requireNonemptyString(manifest, "pythonVersion")
219
+ requireNonemptyString(manifest, "artifactToolVersion")
220
+ const pnpmVersion = requireNonemptyString(manifest, "pnpmVersion", { optional: true })
221
+ const nativeDependencies = requireNativeDependencies(manifest)
222
+
223
+ const runtimeNode = path.join(runtimeRoot, "dependencies", "node", "bin", "node")
224
+ const runtimeNodeModules = path.join(runtimeRoot, "dependencies", "node", "node_modules")
225
+ const runtimeBinDir = path.join(runtimeRoot, "dependencies", "bin", "override")
226
+ const runtimePython = path.join(runtimeRoot, "dependencies", "python", "bin", "python3")
227
+ const runtimePythonPackages = path.join(runtimeRoot, "dependencies", "python")
228
+ const runtimeFallbackBinDir = path.join(runtimeRoot, "dependencies", "bin", "fallback")
229
+ const runtimePnpm = pnpmVersion ? path.join(runtimeFallbackBinDir, "pnpm") : null
230
+ const runtimeGit = nativeDependencies.includes("git") ? path.join(runtimeFallbackBinDir, "git") : null
231
+
232
+ for (const [candidate, label] of [
233
+ [runtimeNode, "Bundled runtime Node"],
234
+ [runtimeNodeModules, "Bundled runtime node_modules"],
235
+ [runtimeBinDir, "Bundled runtime binary directory"],
236
+ [runtimePython, "Bundled runtime Python"],
237
+ [runtimePythonPackages, "Bundled runtime Python packages"],
238
+ [runtimeFallbackBinDir, "Bundled runtime fallback binary directory"],
239
+ [runtimePnpm, "Bundled runtime pnpm"],
240
+ [runtimeGit, "Bundled runtime git"],
241
+ ]) {
242
+ if (candidate) await requireContainedRealPath(candidate, label, realRuntimeRoot, realpathImpl)
243
+ }
244
+
245
+ await requireExecutableFile(
246
+ runtimeNode,
247
+ "Bundled runtime Node",
248
+ "CODEX_RUNTIME_NODE_NOT_EXECUTABLE",
249
+ statImpl,
250
+ accessImpl,
251
+ )
252
+ await requireDirectory(runtimeNodeModules, "Bundled runtime node_modules", statImpl)
253
+ await requireDirectory(runtimeBinDir, "Bundled runtime binary directory", statImpl)
254
+ await requireExecutableFile(
255
+ runtimePython,
256
+ "Bundled runtime Python",
257
+ "CODEX_RUNTIME_PYTHON_NOT_EXECUTABLE",
258
+ statImpl,
259
+ accessImpl,
260
+ )
261
+ await requireDirectory(runtimePythonPackages, "Bundled runtime Python packages", statImpl)
262
+ await requireDirectory(runtimeFallbackBinDir, "Bundled runtime fallback binary directory", statImpl)
263
+ if (runtimePnpm) {
264
+ await requireExecutableFile(
265
+ runtimePnpm,
266
+ "Bundled runtime pnpm",
267
+ "CODEX_RUNTIME_PNPM_NOT_EXECUTABLE",
268
+ statImpl,
269
+ accessImpl,
270
+ )
271
+ }
272
+ if (runtimeGit) {
273
+ await requireExecutableFile(
274
+ runtimeGit,
275
+ "Bundled runtime git",
276
+ "CODEX_RUNTIME_GIT_NOT_EXECUTABLE",
277
+ statImpl,
278
+ accessImpl,
279
+ )
280
+ }
281
+
282
+ const runtimeChildEnv = childEnvWithoutExternalLanguagePaths(env)
283
+
284
+ try {
285
+ await execFileImpl(
286
+ runtimeNode,
287
+ ["--input-type=module", "--eval", "await import('@oai/artifact-tool')"],
288
+ {
289
+ cwd: path.dirname(runtimeNodeModules),
290
+ env: runtimeChildEnv,
291
+ encoding: "utf8",
292
+ timeout: 15_000,
293
+ maxBuffer: 64 * 1024,
294
+ killSignal: "SIGKILL",
295
+ windowsHide: true,
296
+ },
297
+ )
298
+ } catch (error) {
299
+ fail("CODEX_RUNTIME_ARTIFACT_TOOL_UNAVAILABLE", "Bundled @oai/artifact-tool could not be imported", error)
300
+ }
301
+
302
+ try {
303
+ await execFileImpl(
304
+ runtimePython,
305
+ ["-I", "-c", "import docx"],
306
+ {
307
+ cwd: runtimePythonPackages,
308
+ env: runtimeChildEnv,
309
+ encoding: "utf8",
310
+ timeout: 15_000,
311
+ maxBuffer: 64 * 1024,
312
+ killSignal: "SIGKILL",
313
+ windowsHide: true,
314
+ },
315
+ )
316
+ } catch (error) {
317
+ fail("CODEX_RUNTIME_DOCUMENTS_PYTHON_UNAVAILABLE", "Bundled Python could not import docx", error)
318
+ }
319
+
320
+ return {
321
+ bundleVersion,
322
+ gitPath: runtimeGit,
323
+ nodePath: runtimeNode,
324
+ nodeModulesPath: runtimeNodeModules,
325
+ pnpmPath: runtimePnpm,
326
+ pythonPath: runtimePython,
327
+ pythonLibrariesPath: runtimePythonPackages,
328
+ overrideBinPath: runtimeBinDir,
329
+ fallbackBinPath: runtimeFallbackBinDir,
330
+ }
331
+ }