Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 38 additions & 63 deletions hypaware-core/plugins-workspace/ai-gateway/src/session_command.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import https from 'node:https'
import os from 'node:os'
import path from 'node:path'

import { readRolloutSessionMeta } from '../../../../src/core/codex/rollout_session_meta.js'
import { configuredGatewayEndpoint } from '../../../../src/core/config/gateway_endpoint.js'
import { resolveLiveGatewayEndpointFromStatus } from '../../../../src/core/daemon/status.js'
import { readObservabilityEnv } from '../../../../src/core/observability/env.js'
Expand Down Expand Up @@ -512,7 +513,7 @@ export function resolveGatewayEndpointForCli(ctx) {
* age, and point at the explicit-id escape hatch.
*
* **Refuses on a legacy rollout** that carries no `session_id` field at all,
* rather than falling back to its thread id: see `readRolloutMeta`.
* rather than falling back to its thread id: see `legacyRolloutError`.
*
* @param {{ env: NodeJS.ProcessEnv, cwd: string, maxScan?: number, maxAgeMs?: number, now?: number }} args
* @returns {SessionIdResolution}
Expand Down Expand Up @@ -558,7 +559,7 @@ export function resolveSessionIdForCli(args) {
}
}

/** @type {{ threadId: string, sessionId: string | undefined, cwd: string, file: string }[]} */
/** @type {{ threadId: string, sessionId: string | undefined, cwd: string | undefined, file: string }[]} */
const candidates = []
for (const file of scan.files) {
const meta = readRolloutMeta(file)
Expand Down Expand Up @@ -643,7 +644,7 @@ export function resolveSessionIdForCli(args) {
* @returns {SessionIdResolution}
*/
function resolveFromStatedThread(scan, sessionsDir, threadId, maxScan) {
/** @type {{ threadId: string, sessionId: string | undefined, cwd: string, file: string }[]} */
/** @type {{ threadId: string, sessionId: string | undefined, cwd: string | undefined, file: string }[]} */
const matches = []
for (const file of scan.files) {
const meta = readRolloutMeta(file)
Expand Down Expand Up @@ -782,72 +783,46 @@ function describeAge(ms) {
}

/**
* Read `payload.id` (the thread), `payload.session_id` (the session container
* the drop keys on) and `payload.cwd` off a rollout's first line. Only a
* bounded prefix is read: a rollout grows without limit, but its `session_meta`
* header is the first record.
*
* **The raw JSON line is what is parsed, deliberately.** Codex's
* `SessionMetaLine` has a hand-written `Deserialize` that BACK-FILLS
* `session_id` from `id` when the field is absent, so anything reading a
* deserialized `session_meta` gets the *thread* id handed back under the name
* `session_id` on every legacy rollout - the wrong key, silently, exactly the
* defect this resolution exists to remove. Reading the line means an absent
* field is visible as absent, and `sessionId: undefined` is then a refusal at
* the call site rather than a guess.
*
* Two shape guards keep "read the raw line" from becoming "trust whatever the
* line says". The record must be the `session_meta` header (the only record type
* that states the container; `codex/src/rollout-cwd.js` type-checks the same
* line for the same reason), and a **blank** `session_id` counts as absent, like
* a blank environment variable in `statedEnv`. Both mean the container is not
* readable from this file, and unreadable is a refusal: a present-but-unusable
* key would otherwise be reported as the answer and then match nothing at the
* drop, which is the failure mode this whole resolution exists to remove.
* The thread id, session container and cwd a rollout's `session_meta` header
* states, or `undefined` when the file states no thread at all.
*
* The read itself is `readRolloutSessionMeta`, shared with `@hypaware/codex`'s
* live cwd resolver, which asks this exact line the same question for the
* `.hypignore` match. Both answers gate a privacy control and a wrong one is
* silent, and the rules for reading the line drifted apart twice while each
* caller kept its own copy (#453, #459). So the rules live in the reader and
* this function is only the caller's shape: what a resolution here needs and
* what it may refuse on.
* @ref LLP 0150 [constrained-by]: one reader for `session_meta`, not one per caller
*
* Three of the reader's guarantees are what the resolvers above rest on, and
* none of them is restated here:
*
* - **The raw JSON line is what is parsed.** Codex's `SessionMetaLine` has a
* hand-written `Deserialize` that BACK-FILLS `session_id` from `id` when the
* field is absent, so anything reading a deserialized `session_meta` gets
* the *thread* id handed back under the name `session_id` on every legacy
* rollout: the wrong key, silently, exactly the defect #458 removed.
* - **The record must be the `session_meta` header.** Other rollout records
* carry `payload.id` and `payload.cwd` too (a `turn_context` does), and read
* as the header they yield a confident id belonging to no session.
* - **A blank field is an absent one**, so `sessionId: undefined` is a refusal
* at the call site rather than a key that matches nothing at the drop.
*
* `cwd` is passed through as the reader gives it, `undefined` included: only the
* cwd path consults it, and there an absent value simply matches no invocation
* cwd. The stated-thread path answers about a thread the client named, so a
* rollout whose header records no usable `cwd` still answers it.
*
* @param {string} file
* @returns {{ threadId: string, sessionId: string | undefined, cwd: string } | undefined}
* @returns {{ threadId: string, sessionId: string | undefined, cwd: string | undefined } | undefined}
* @ref LLP 0067#cli-session-id [implements]: an absent or unusable session_id is
* unresolvable, never the back-filled thread id
*/
function readRolloutMeta(file) {
/** @type {number | undefined} */
let fd
try {
fd = fs.openSync(file, 'r')
const buf = Buffer.alloc(64 * 1024)
const read = fs.readSync(fd, buf, 0, buf.length, 0)
const text = buf.subarray(0, read).toString('utf8')
const newline = text.indexOf('\n')
const line = newline === -1 ? text : text.slice(0, newline)
const parsed = JSON.parse(line)
if (!parsed || typeof parsed !== 'object' || parsed.type !== 'session_meta') return undefined
const payload = parsed.payload
if (!payload || typeof payload !== 'object') return undefined
const fields = /** @type {Record<string, unknown>} */ (payload)
const id = fields.id
const cwd = fields.cwd
const sessionId = fields.session_id
if (typeof id !== 'string' || id.length === 0) return undefined
if (typeof cwd !== 'string' || cwd.length === 0) return undefined
return {
threadId: id,
// Returned byte-identical (an opaque provider token, LLP 0066 R5): only
// the usability test trims.
sessionId: typeof sessionId === 'string' && sessionId.trim().length > 0 ? sessionId : undefined,
cwd,
}
} catch {
return undefined
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd)
} catch {
/* already closed */
}
}
}
const meta = readRolloutSessionMeta(file)
if (meta?.threadId === undefined) return undefined
return { threadId: meta.threadId, sessionId: meta.sessionId, cwd: meta.cwd }
}

/**
Expand Down
31 changes: 30 additions & 1 deletion hypaware-core/plugins-workspace/codex/src/backfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import fs from 'node:fs/promises'
import path from 'node:path'

import { sessionMetaCwd } from '../../../../src/core/codex/rollout_session_meta.js'
import { createUsagePolicyResolver } from '../../../../src/core/usage-policy/index.js'
import {
AI_GATEWAY_MESSAGES_DATASET,
Expand Down Expand Up @@ -545,7 +546,17 @@ function buildSession(args) {
sessionId: stringValue(metaPayload.session_id) ?? threadId,
threadId,
startedAtMs: timestampToMs(metaPayload.timestamp),
cwd: firstString(stringValue(metaPayload.cwd), firstTurnString(turnPayloads, 'cwd')),
// `session.cwd` gates the same `.hypignore` drop the live path gates (see
// `runCodexBackfill`'s `resolver.resolve(session.cwd)`), so it uses the
// shared reader's `cwd` predicate rather than the plain `stringValue` the
// rest of these fields use. `stringValue` accepts `' '` and any relative
// path, both of which reach `path.resolve` in the matcher and produce a
// verdict about whatever directory `hyp backfill` was invoked from. This
// provider cannot delegate to `readRolloutSessionMeta` (it reads the whole
// file and folds `turn_context`), but it can share the one predicate.
// @ref LLP 0150#usable-cwd [constrained-by]: blank-and-relative means "no
// cwd" at every site that feeds the gate, not just the first-line reader
cwd: firstString(sessionMetaCwd(metaPayload.cwd), firstTurnCwd(turnPayloads)),
gitOriginUrl: git ? redactRemoteUserinfo(firstString(stringValue(git.repository_url), stringValue(git.origin_url))) : undefined,
gitCommit: git ? firstString(stringValue(git.commit_hash), stringValue(git.commit)) : undefined,
gitBranch: git ? stringValue(git.branch) : undefined,
Expand Down Expand Up @@ -822,6 +833,24 @@ function firstTurnString(turns, key) {
return undefined
}

/**
* The first `turn_context.cwd` that is a usable container. Separate from
* `firstTurnString` because it applies the shared `cwd` predicate: a turn whose
* `cwd` is blank or relative is skipped rather than accepted as the session's
* directory, so the fallback cannot smuggle in what the `session_meta` branch
* refuses.
*
* @param {Record<string, unknown>[]} turns
* @returns {string | undefined}
*/
function firstTurnCwd(turns) {
for (const turn of turns) {
const cwd = sessionMetaCwd(turn.cwd)
if (cwd) return cwd
}
return undefined
}

/** @param {Record<string, unknown>[]} turns @param {string} key */
function firstTurnObject(turns, key) {
for (const turn of turns) {
Expand Down
46 changes: 7 additions & 39 deletions hypaware-core/plugins-workspace/codex/src/rollout-cwd.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,13 @@
import fs from 'node:fs'
import path from 'node:path'

import { readRolloutSessionMeta } from '../../../../src/core/codex/rollout_session_meta.js'
import { sessionIdFromPath } from './backfill.js'
import { isPlainObject, parseMaybeJson, stringValue } from 'hypaware/core/util'

/**
* @import { RolloutCwdResolver, RolloutCwdResolverOptions, RolloutDirent } from './types.js'
*/

// Only the first `session_meta` line is read, so a bounded prefix is enough:
// Codex writes session_meta as line 1 of the rollout at session start. Reading
// a prefix (never the whole session) keeps the capture hot path cheap even for
// a long, large rollout.
const FIRST_LINE_MAX_BYTES = 64 * 1024

// A negative resolution (no cwd found — the rollout is not yet written on the
// session's first exchange, or a momentary read error) is trusted only briefly
// before it is re-checked, mirroring the usage-policy resolver's 5s TTL. A
Expand Down Expand Up @@ -79,6 +73,11 @@ export function createRolloutCwdResolver(opts) {
* line that is not a `session_meta` record all yield `undefined` (fail open on
* a genuinely absent rollout, matching the nullable `cwd` column).
*
* The header read itself is `readRolloutSessionMeta`, shared with the
* `hyp session` id resolver: two privacy controls read this one line, and the
* rules for reading it drifted apart twice while each kept its own copy.
* @ref LLP 0150 [constrained-by]: one reader for `session_meta`, not one per caller
*
* @param {string} sessionsDir
* @param {string} sessionId
* @param {(dirPath: string, options: { withFileTypes: true }) => RolloutDirent[]} readdirSync
Expand All @@ -87,12 +86,7 @@ export function createRolloutCwdResolver(opts) {
function readRolloutCwd(sessionsDir, sessionId, readdirSync) {
const rolloutPath = findRolloutFile(sessionsDir, sessionId, readdirSync)
if (!rolloutPath) return undefined
const firstLine = readFirstLine(rolloutPath)
if (!firstLine) return undefined
const row = parseMaybeJson(firstLine)
if (!isPlainObject(row) || stringValue(row.type) !== 'session_meta') return undefined
const payload = isPlainObject(row.payload) ? row.payload : undefined
return stringValue(payload?.cwd)
return readRolloutSessionMeta(rolloutPath)?.cwd
}

/**
Expand Down Expand Up @@ -160,29 +154,3 @@ function defaultReaddir(dirPath, options) {
function isRolloutFileName(name) {
return name.startsWith('rollout-') && (name.endsWith('.jsonl') || name.endsWith('.json'))
}

/**
* Read a bounded prefix of a file and return its first line (without the
* trailing newline). Returns `undefined` on any read error.
*
* @param {string} filePath
* @returns {string | undefined}
*/
function readFirstLine(filePath) {
let fd
try {
fd = fs.openSync(filePath, 'r')
const buffer = Buffer.alloc(FIRST_LINE_MAX_BYTES)
const bytesRead = fs.readSync(fd, buffer, 0, FIRST_LINE_MAX_BYTES, 0)
if (bytesRead === 0) return undefined
const text = buffer.toString('utf8', 0, bytesRead)
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
} catch {
return undefined
} finally {
if (fd !== undefined) {
try { fs.closeSync(fd) } catch { /* already closed */ }
}
}
}
Loading
Loading