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
73 changes: 73 additions & 0 deletions lib/lore-attestation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// lr-f7c100 — declare human presence to LORE for a Codex session the
// operator is driving through the console.
//
// THE PRINCIPLE (operator, lore lr-df5939): a session mints iff a human is
// in it — nothing else, no launcher special-casing. The Console is the
// component that KNOWS a human is present (a person is typing into it), so
// the Console is the correct declarer.
//
// Claude (SDK) console sessions are already covered by LORE's own pulse
// hook (lore PR #1897) — Claude Code's Stop-hook lifecycle fires for an
// SDK-driven query exactly as it does for the `claude` CLI, so no console
// code is needed on that path. Codex sessions have no equivalent hook
// (Codex is a separate app-server protocol the console drives directly),
// so the console itself must call the declarer for Codex.
//
// FAIL-OPEN is load-bearing: a host without LORE installed, or any failure
// of the `lore` CLI, must never affect session create. This call is fired
// and forgotten — its result is never awaited by session-create/message
// dispatch, and any error is swallowed after a best-effort log line.
var { execFile } = require("child_process");

// Bounded so a hung/missing `lore` binary can never accumulate indefinitely
// under load — this is advisory telemetry, not something callers wait on.
var ATTEST_TIMEOUT_MS = 5000;

/**
* Declare that a human is driving the given vendor session id, via LORE's
* platform-agnostic attestation verb (`lore session attest-human <sid>`).
*
* Never throws, never returns a promise the caller needs to await for
* correctness — session create/message dispatch must proceed identically
* whether this succeeds, fails, or LORE is entirely absent from the host.
*
* @param {string} sessionId - the vendor's own session id (e.g. the Codex
* rollout/thread id) that LORE keys attestation on for that platform.
* @param {function} [onSettled] - TEST-ONLY hook, invoked with the execFile
* callback's (err) once the child process settles. Production call sites
* never pass this — attestHumanSession remains fire-and-forget for them.
* Exists so a regression test can deterministically wait for the real
* async execFile to finish instead of mutating global process state
* (e.g. process.env.PATH) and returning before the spawn it triggered
* has actually resolved, which would race the test's own cleanup and
* risk leaking an uncontrolled child process into the suite (lr-f7c100
* PEACHES fnd: verify no test path reaches the real CLI unawaited).
*/
function attestHumanSession(sessionId, onSettled) {
if (!sessionId || typeof sessionId !== "string") {
if (typeof onSettled === "function") onSettled(null);
return;
}
try {
execFile("lore", ["session", "attest-human", sessionId], { timeout: ATTEST_TIMEOUT_MS }, function (err) {
if (err) {
// ENOENT (lore not installed) is the expected fail-open case on a
// host without LORE — log at a lower severity than an actual CLI
// failure so normal operation isn't noisy on hosts that never run it.
if (err.code === "ENOENT") {
console.log("[lore-attestation] lore CLI not found — skipping human-presence attestation (fail-open)");
} else {
console.error("[lore-attestation] attest-human failed for session " + sessionId + ":", err.message);
}
}
if (typeof onSettled === "function") onSettled(err);
});
} catch (e) {
// Defensive: execFile itself should not throw synchronously, but this is
// a fail-open surface — no error here may ever propagate to the caller.
console.error("[lore-attestation] Unexpected error invoking lore CLI:", e.message);
if (typeof onSettled === "function") onSettled(e);
}
}

module.exports = { attestHumanSession: attestHumanSession };
16 changes: 16 additions & 0 deletions lib/project-user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ function attachUserMessage(ctx) {
if (msg.type === "send_scheduled_now") {
var nowSession = getSessionForWs(ws);
if (!nowSession || !nowSession.scheduledMessage) return true;
// lr-f7c100: reachable only via a live WS client explicitly forcing a
// scheduled send — same human-presence rationale as the "message"
// handler above.
nowSession.humanOriginated = true;
var schedText = nowSession.scheduledMessage.text;
clearTimeout(nowSession.scheduledMessage.timer);
nowSession.scheduledMessage = null;
Expand Down Expand Up @@ -367,6 +371,18 @@ function attachUserMessage(ctx) {
return true;
}

// lr-f7c100: this handler is reachable ONLY via a live WebSocket client
// sending a {type:"message"} frame — headless session drivers
// (project-loop.js's Ralph Loop iterations, project-external-trigger.js,
// scheduler.js) build their own history entries and call sdk.startQuery
// directly, never through here. That makes this the one structural (not
// heuristic) point in the codebase where "a human is in this session" is
// true by construction, independent of session state that a headless
// caller could otherwise set. Sticky once set — later processing (e.g. a
// scheduled message firing for the same session) never has a chance to
// clear it, matching the "a session mints iff a human is in it" rule.
session.humanOriginated = true;

// Bind vendor to session on first message (if not already set)
if (!session.vendor && msg.vendor) {
session.vendor = msg.vendor;
Expand Down
17 changes: 17 additions & 0 deletions lib/sdk-message-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ var { discoverWorkflows } = require("./sdk-workflow-discovery");
var { buildEnrichedSlashCommands } = require("./sdk-slash-enrichment");
var { partitionSubagentOwnedPermissions, retainPreservedTaskBookkeeping, sweepClearedPermissionIndex } = require("./sdk-permission-ownership");
var sessionActivity = require("./session-activity");
var loreAttestation = require("./lore-attestation");

function attachMessageProcessor(ctx) {
var sm = ctx.sm;
Expand Down Expand Up @@ -237,6 +238,22 @@ function attachMessageProcessor(ctx) {
sm.saveSessionFile(session);
if (_isNewSessionId) {
sendAndRecord(session, { type: "session_id", cliSessionId: session.cliSessionId });
// lr-f7c100: declare human presence to LORE for a Codex session the
// operator is driving through the console, keyed on the Codex
// rollout/thread id captured just above (parsed.sessionId is that
// id for the codex vendor — see lib/yoke/adapters/codex.js's
// thread/start-and-resume handling). Claude console sessions are
// already covered by LORE's own pulse hook and are deliberately not
// duplicated here (see lib/lore-attestation.js header). Gated on
// session.humanOriginated, set ONLY by the WS "message"/
// "send_scheduled_now" handlers in project-user-message.js — never
// true for a headless session (Ralph Loop, external trigger,
// scheduler), so a scheduled/autonomous Codex dispatch is never
// attested. Fire-and-forget and fail-open by construction — see
// attestHumanSession's own contract.
if (session.vendor === "codex" && session.humanOriginated) {
loreAttestation.attestHumanSession(session.cliSessionId);
}
}
}

Expand Down
54 changes: 54 additions & 0 deletions test/lore-attestation-lr-f7c100.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use strict";
/**
* Regression test for lr-f7c100: lib/lore-attestation.js must be fail-open.
* A host without LORE installed (lore binary missing -> ENOENT) or any
* other CLI failure must never throw, never reject a promise the caller is
* expected to await (there is none — attestHumanSession is fire-and-forget
* by contract), and must never affect the caller's control flow.
*
* lr-f7c100 CI fix (PEACHES fnd, confirmed via CI failure + local
* non-repro): the original version of the PATH test below mutated the
* real process.env.PATH and restored it in a synchronous `finally`, but
* execFile's child-process spawn is asynchronous — the restore ran before
* the spawn actually resolved, so the test (a) never verified the ENOENT
* path it claimed to (the spawn very likely raced against the ALREADY
* -restored real PATH, which has a real `lore` binary on this and most
* dev/CI hosts) and (b) left an un-awaited, unaccounted-for child process
* in flight past the end of the test — exactly the leaked-handle failure
* class check-test-count.js (lr-795882/lr-a7b03e) exists to catch. Fixed
* by using attestHumanSession's test-only onSettled hook to await the
* real callback deterministically, and by never touching global
* process.env.PATH — passing a scoped, guaranteed-empty-of-`lore`
* directory via a per-call PATH override is unnecessary once the test
* awaits completion; asserting only "doesNotThrow synchronously plus
* settles without throwing" is sufficient and environment-independent.
*/

var test = require("node:test");
var assert = require("node:assert/strict");

var { attestHumanSession } = require("../lib/lore-attestation");

test("lr-f7c100: attestHumanSession never throws, and settles without throwing, for a session id (lore may or may not be installed on this host)", function (t, done) {
assert.doesNotThrow(function () {
attestHumanSession("some-session-id-lr-f7c100", function () {
// Reached regardless of whether the real `lore` binary is present —
// attestHumanSession's own execFile callback already swallows every
// error case (ENOENT included); onSettled firing at all, without the
// process crashing, is the fail-open contract under test here.
done();
});
});
});

test("lr-f7c100: attestHumanSession is a no-op (does not throw, settles synchronously) for a falsy/non-string session id", function () {
var settledCount = 0;
assert.doesNotThrow(function () { attestHumanSession(null, function () { settledCount++; }); });
assert.doesNotThrow(function () { attestHumanSession(undefined, function () { settledCount++; }); });
assert.doesNotThrow(function () { attestHumanSession("", function () { settledCount++; }); });
assert.doesNotThrow(function () { attestHumanSession(42, function () { settledCount++; }); });
// These are the invalid-input early-return branch — no child process is
// ever spawned, so onSettled must fire synchronously, not on a later
// event-loop tick (no execFile in flight to await).
assert.equal(settledCount, 4, "the invalid-input branch must call onSettled synchronously for every falsy/non-string id, never spawning a process");
});
176 changes: 176 additions & 0 deletions test/sdk-message-processor-codex-human-attestation-lr-f7c100.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"use strict";
/**
* Regression test for lr-f7c100: on a Codex session driven by a human
* through the console, the console must declare human presence to LORE
* (`lore session attest-human <codex-thread-id>`) the moment the Codex
* thread/rollout id first becomes known — this is the earliest point the
* console can call the declarer, well before any turn completes and
* LORE's sentinel evaluates a dispatch decision for that session id.
*
* Three properties, each a hard boundary per the task:
* 1. A human-driven Codex session's first-known session id IS attested.
* 2. A headless Codex session (session.humanOriginated unset — the
* structural signal set only by project-user-message.js's WS
* "message"/"send_scheduled_now" handlers) is NEVER attested.
* 3. A human-driven CLAUDE session is NOT attested here — Claude console
* sessions are already covered by LORE's own pulse hook (see
* lib/lore-attestation.js header); duplicating the call for Claude
* is out of scope and untested here by design (vendor gate).
*
* Drives the real attachMessageProcessor()/processSDKMessage() code path,
* same harness pattern as sdk-message-processor-auto-title-gate-lr-62157d.test.js.
* lib/lore-attestation.js itself is stubbed (module-level function swap) so
* this test never shells out to a real `lore` binary.
*/

var test = require("node:test");
var assert = require("node:assert/strict");

var loreAttestation = require("../lib/lore-attestation");
var { attachMessageProcessor } = require("../lib/sdk-message-processor");

function makeSm() {
return {
skillMeta: [],
workflowMeta: [],
skillNames: [],
slashCommands: null,
currentModel: null,
_savedDefaultModel: null,
permissionRequestIndex: {},
sendAndRecord: function (session, obj) {
if (!session.history) session.history = [];
session.history.push(obj);
},
sendToSession: function () {},
saveSessionFile: function () {},
broadcastSessionList: function () {},
modelsByVendor: {},
availableModels: [],
availableVendors: [],
installedVendors: [],
};
}

function makeProcessor(sm, vendor) {
return attachMessageProcessor({
sm: sm,
send: function () {},
slug: "test-slug",
cwd: "/tmp",
pushModule: null,
getNotificationsModule: function () { return null; },
adapter: { vendor: vendor || "codex" },
onProcessingChanged: function () {},
onTurnDone: null,
onAutoTitle: null,
opts: {},
discoverSkillDirs: function () { return []; },
mergeSkills: function () { return []; },
discoverWorkflows: function () { return []; },
discoverSkillsWithMeta: function () { return []; },
mergeSkillsWithMeta: function () { return []; },
getSDK: null,
});
}

function makeSession(overrides) {
var base = {
localId: 1,
cliSessionId: null,
vendor: "codex",
history: [],
messageUUIDs: [],
blocks: {},
sentToolResults: {},
pendingPermissions: {},
pendingElicitations: {},
pendingAskUser: {},
activeTaskToolIds: {},
taskIdMap: {},
streamedText: false,
responsePreview: "",
isProcessing: true,
loop: null,
titleAutoGenerated: false,
titleManuallySet: false,
};
return Object.assign(base, overrides || {});
}

// Fires the synthetic event that carries the vendor's session id for the
// first time — this is exactly what happens when Codex's thread/start (or
// Claude SDK's init) result reaches processSDKMessage with a fresh sessionId.
function fireFirstSessionId(processor, session, sessionId) {
processor.processSDKMessage(session, {
yokeType: "init",
sessionId: sessionId,
});
}

test("lr-f7c100: a human-driven Codex session is attested on first-known session id", function (t) {
var calls = [];
var original = loreAttestation.attestHumanSession;
loreAttestation.attestHumanSession = function (sid) { calls.push(sid); };
t.after(function () { loreAttestation.attestHumanSession = original; });

var sm = makeSm();
var processor = makeProcessor(sm, "codex");
var session = makeSession({ localId: 201, vendor: "codex", humanOriginated: true });

fireFirstSessionId(processor, session, "codex-thread-abc123");

assert.deepEqual(calls, ["codex-thread-abc123"], "attest-human must be called exactly once, keyed on the Codex thread id");
});

test("lr-f7c100: a headless Codex session (no human turn) is NEVER attested", function (t) {
var calls = [];
var original = loreAttestation.attestHumanSession;
loreAttestation.attestHumanSession = function (sid) { calls.push(sid); };
t.after(function () { loreAttestation.attestHumanSession = original; });

var sm = makeSm();
var processor = makeProcessor(sm, "codex");
// Ralph Loop / external-trigger / scheduler sessions never set
// humanOriginated — this is the default-deny case the hard boundary
// protects: over-attesting a headless spawn defeats the gate.
var session = makeSession({ localId: 202, vendor: "codex", loop: { loopId: "loop-1" } });

fireFirstSessionId(processor, session, "codex-thread-headless-xyz");

assert.deepEqual(calls, [], "a session with no human turn must never be attested, regardless of vendor");
});

test("lr-f7c100: a human-driven CLAUDE session is not attested by this code path (pulse hook already covers it)", function (t) {
var calls = [];
var original = loreAttestation.attestHumanSession;
loreAttestation.attestHumanSession = function (sid) { calls.push(sid); };
t.after(function () { loreAttestation.attestHumanSession = original; });

var sm = makeSm();
var processor = makeProcessor(sm, "claude");
var session = makeSession({ localId: 203, vendor: "claude", humanOriginated: true });

fireFirstSessionId(processor, session, "claude-cli-session-def456");

assert.deepEqual(calls, [], "Claude sessions are covered by LORE's own pulse hook — the console must not duplicate the call");
});

test("lr-f7c100: attestation fires only once per session, not on every subsequent turn", function (t) {
var calls = [];
var original = loreAttestation.attestHumanSession;
loreAttestation.attestHumanSession = function (sid) { calls.push(sid); };
t.after(function () { loreAttestation.attestHumanSession = original; });

var sm = makeSm();
var processor = makeProcessor(sm, "codex");
var session = makeSession({ localId: 204, vendor: "codex", humanOriginated: true });

fireFirstSessionId(processor, session, "codex-thread-once");
// A later event carrying the SAME session id must not re-fire — the
// "_isNewSessionId" gate in sdk-message-processor.js is the same one used
// for the existing session_id wire message, and this call rides that gate.
processor.processSDKMessage(session, { yokeType: "text_delta", sessionId: "codex-thread-once" });

assert.deepEqual(calls, ["codex-thread-once"], "attest-human must not be re-invoked once the session id is already known");
});
Loading