@wipcomputer/wip-ldm-os 0.4.85-alpha.15 → 0.4.85-alpha.17
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 +2 -1
- package/scripts/test-crc-agentid-tenant-boundary.mjs +1 -1
- package/scripts/test-crc-e2ee-key-persistence.mjs +2 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +139 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +3 -0
- package/src/hosted-mcp/app/pair.html +21 -3
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +62 -1
- package/src/hosted-mcp/server.mjs +106 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-ldm-os",
|
|
3
|
-
"version": "0.4.85-alpha.
|
|
3
|
+
"version": "0.4.85-alpha.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
|
|
6
6
|
"engines": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"test:crc-agentid-tenant-boundary": "node scripts/test-crc-agentid-tenant-boundary.mjs",
|
|
33
33
|
"test:crc-pair-login-flow": "node scripts/test-crc-pair-login-flow.mjs",
|
|
34
34
|
"test:crc-pair-status-poll-token": "node scripts/test-crc-pair-status-poll-token.mjs",
|
|
35
|
+
"test:crc-pair-relink-audit-and-rotation": "node scripts/test-crc-pair-relink-audit-and-rotation.mjs",
|
|
35
36
|
"test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
|
|
36
37
|
"test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
|
|
37
38
|
"fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
|
|
@@ -29,7 +29,7 @@ assertContains("await saveApiKey(apiKey, agentId, { handle: credentialLabel });"
|
|
|
29
29
|
assertContains("p.handle = identity.handle;", "pair stores display handle separately");
|
|
30
30
|
assertContains("handle: identity.handle,", "relay metadata returns display handle");
|
|
31
31
|
assertContains("codexDaemons.has(identity.agentId)", "daemon presence uses tenant id");
|
|
32
|
-
assertContains("
|
|
32
|
+
assertContains("codexDaemonPubkeyRegistry.get(identity.agentId)", "daemon pubkey uses tenant id");
|
|
33
33
|
assertContains("agentId: identity.agentId,", "relay tickets bind tenant id");
|
|
34
34
|
assertContains("handle: identity.handle,", "relay tickets preserve display handle");
|
|
35
35
|
assertContains("codexDaemons.set(identity.agentId, ws);", "daemon ws keyed by tenant id");
|
|
@@ -41,6 +41,7 @@ function createFakePrisma() {
|
|
|
41
41
|
rows,
|
|
42
42
|
async $executeRawUnsafe(sql, tenantId, pubkey, cryptoVersionsJson) {
|
|
43
43
|
if (/CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_keys/.test(sql)) return;
|
|
44
|
+
if (/CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit/.test(sql)) return;
|
|
44
45
|
if (/INSERT INTO codex_daemon_e2ee_keys/.test(sql)) {
|
|
45
46
|
rows.set(tenantId, {
|
|
46
47
|
tenant_id: tenantId,
|
|
@@ -50,6 +51,7 @@ function createFakePrisma() {
|
|
|
50
51
|
});
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
54
|
+
if (/INSERT INTO codex_daemon_e2ee_key_audit/.test(sql)) return;
|
|
53
55
|
throw new Error("unexpected fake prisma execute: " + sql);
|
|
54
56
|
},
|
|
55
57
|
async $queryRawUnsafe(sql) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
codexDaemonPubkeyFingerprint,
|
|
4
|
+
createCodexDaemonPubkeyRegistry,
|
|
5
|
+
} from "../src/hosted-mcp/codex-relay-e2ee-registry.mjs";
|
|
6
|
+
|
|
7
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
8
|
+
const pairHtml = readFileSync("src/hosted-mcp/app/pair.html", "utf8");
|
|
9
|
+
const loginHtml = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
|
|
10
|
+
const registrySource = readFileSync("src/hosted-mcp/codex-relay-e2ee-registry.mjs", "utf8");
|
|
11
|
+
const ticket = readFileSync("ai/product/bugs/codex-remote-control/2026-05-05--codex--remote-control-pair-relink-audit-and-rotation.md", "utf8");
|
|
12
|
+
|
|
13
|
+
function assertContains(haystack, needle, label) {
|
|
14
|
+
if (!haystack.includes(needle)) {
|
|
15
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assert(condition, label, detail = "") {
|
|
20
|
+
if (!condition) throw new Error(`${label}${detail ? ": " + detail : ""}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createSilentLogger() {
|
|
24
|
+
return { log() {}, error() {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
assertContains(server, "const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;", "short pair presence ttl");
|
|
28
|
+
assertContains(server, "const codexPairPresenceTokens = new Map();", "pair presence token store");
|
|
29
|
+
assertContains(server, "function generateCodexPairPresenceToken(agentId)", "pair presence token mint");
|
|
30
|
+
assertContains(server, "function consumeCodexPairPresenceToken(token, agentId)", "pair presence token consume");
|
|
31
|
+
assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(agentId)", "registration mints pair presence token");
|
|
32
|
+
assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId)", "authentication mints pair presence token");
|
|
33
|
+
assertContains(server, 'error: "fresh_presence_required"', "pair-complete fresh presence rejection");
|
|
34
|
+
assertContains(server, "consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)", "pair-complete consumes pair presence token");
|
|
35
|
+
assertContains(server, 'json(res, 404, { error: "invalid or already-used code" });', "pair code reuse rejection");
|
|
36
|
+
assertContains(server, 'json(res, 410, { error: "code expired or already used" });', "pair code expiry rejection");
|
|
37
|
+
assertContains(server, "invalidateCodexBrowserSessionsForAgent(identity.agentId, \"daemon key replaced\")", "daemon replacement invalidates stale browser sessions");
|
|
38
|
+
assertContains(server, "p.replaced_daemon_key = !!daemonKeyResult?.replaced;", "pair state records replacement status");
|
|
39
|
+
assertContains(server, "replaced_daemon_key: !!p.replaced_daemon_key", "pair-status exposes relink replacement status");
|
|
40
|
+
assertContains(pairHtml, "codex_pair_presence_token: getPairPresenceToken()", "pair page sends pair presence token");
|
|
41
|
+
assertContains(pairHtml, "fresh_presence_required", "pair page handles fresh presence error");
|
|
42
|
+
assertContains(pairHtml, "Remote Control relinked this laptop.", "pair page gives relink message");
|
|
43
|
+
assertContains(loginHtml, "wip_codex_pair_presence_token", "login carries pair presence token into pair page");
|
|
44
|
+
assertContains(registrySource, "CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit", "pair audit table");
|
|
45
|
+
assertContains(registrySource, "old_pubkey_fingerprint", "audit stores old key fingerprint");
|
|
46
|
+
assertContains(registrySource, "new_pubkey_fingerprint", "audit stores new key fingerprint");
|
|
47
|
+
assertContains(ticket, "status: done", "ticket marked done");
|
|
48
|
+
|
|
49
|
+
const oldFingerprint = codexDaemonPubkeyFingerprint("old-spki-key");
|
|
50
|
+
const newFingerprint = codexDaemonPubkeyFingerprint("new-spki-key");
|
|
51
|
+
assert(oldFingerprint && oldFingerprint.startsWith("sha256:"), "fingerprint has sha256 prefix");
|
|
52
|
+
assert(oldFingerprint !== newFingerprint, "fingerprint changes when daemon key changes");
|
|
53
|
+
|
|
54
|
+
const registry = createCodexDaemonPubkeyRegistry({
|
|
55
|
+
usePrisma: false,
|
|
56
|
+
devMode: false,
|
|
57
|
+
logger: createSilentLogger(),
|
|
58
|
+
});
|
|
59
|
+
const first = await registry.register("acct:test-user-a", "old-spki-key", ["e2ee-v1"], "pair-complete");
|
|
60
|
+
assert(first.registered === true, "first pair registers key");
|
|
61
|
+
assert(first.replaced === false, "first pair is not replacement");
|
|
62
|
+
const second = await registry.register("acct:test-user-a", "new-spki-key", ["e2ee-v1"], "pair-complete");
|
|
63
|
+
assert(second.registered === true, "relink registers new key");
|
|
64
|
+
assert(second.replaced === true, "relink replacement is detected");
|
|
65
|
+
assert(second.old_fingerprint === oldFingerprint, "relink reports old fingerprint");
|
|
66
|
+
assert(second.new_fingerprint === newFingerprint, "relink reports new fingerprint");
|
|
67
|
+
assert(registry.auditLog.length === 2, "registry keeps audit entries");
|
|
68
|
+
assert(registry.auditLog[1].replaced === true, "audit marks replacement");
|
|
69
|
+
assert(registry.auditLog[1].old_pubkey_fingerprint === oldFingerprint, "audit stores old fingerprint");
|
|
70
|
+
assert(registry.auditLog[1].new_pubkey_fingerprint === newFingerprint, "audit stores new fingerprint");
|
|
71
|
+
|
|
72
|
+
const executeCalls = [];
|
|
73
|
+
const persistedRegistry = createCodexDaemonPubkeyRegistry({
|
|
74
|
+
usePrisma: true,
|
|
75
|
+
devMode: false,
|
|
76
|
+
logger: createSilentLogger(),
|
|
77
|
+
prisma: {
|
|
78
|
+
async $executeRawUnsafe(sql, ...args) {
|
|
79
|
+
executeCalls.push({ sql, args });
|
|
80
|
+
return 1;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
await persistedRegistry.register("acct:test-user-b", "persisted-spki-key", ["e2ee-v1"], "daemon-reconnect");
|
|
85
|
+
const auditInsert = executeCalls.find((call) => call.sql.includes("INSERT INTO codex_daemon_e2ee_key_audit"));
|
|
86
|
+
assert(auditInsert, "audit insert executes for persisted registry");
|
|
87
|
+
assert(auditInsert.sql.includes("$7::timestamptz"), "audit insert casts registered_at parameter to timestamptz");
|
|
88
|
+
assert(typeof auditInsert.args[6] === "string" && auditInsert.args[6].includes("T"), "audit insert passes ISO registered_at value");
|
|
89
|
+
|
|
90
|
+
function pairCompleteModel({ hasDaemonPublicKey, pairPresenceOk, previousPubkey, nextPubkey }) {
|
|
91
|
+
if (hasDaemonPublicKey && !pairPresenceOk) return { code: 403, error: "fresh_presence_required" };
|
|
92
|
+
const replaced = !!(previousPubkey && nextPubkey && previousPubkey !== nextPubkey);
|
|
93
|
+
return { code: 200, replaced };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
assert(
|
|
97
|
+
pairCompleteModel({
|
|
98
|
+
hasDaemonPublicKey: true,
|
|
99
|
+
pairPresenceOk: false,
|
|
100
|
+
previousPubkey: "old",
|
|
101
|
+
nextPubkey: "new",
|
|
102
|
+
}).code === 403,
|
|
103
|
+
"ck token alone cannot replace daemon key",
|
|
104
|
+
);
|
|
105
|
+
assert(
|
|
106
|
+
pairCompleteModel({
|
|
107
|
+
hasDaemonPublicKey: true,
|
|
108
|
+
pairPresenceOk: true,
|
|
109
|
+
previousPubkey: "old",
|
|
110
|
+
nextPubkey: "new",
|
|
111
|
+
}).replaced === true,
|
|
112
|
+
"fresh pair presence permits relink",
|
|
113
|
+
);
|
|
114
|
+
assert(
|
|
115
|
+
pairCompleteModel({
|
|
116
|
+
hasDaemonPublicKey: true,
|
|
117
|
+
pairPresenceOk: true,
|
|
118
|
+
previousPubkey: null,
|
|
119
|
+
nextPubkey: "new",
|
|
120
|
+
}).replaced === false,
|
|
121
|
+
"fresh pair presence permits first pair",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
function pairCodeModel(pair, codeKnown, now) {
|
|
125
|
+
if (!codeKnown) return { code: 404, error: "invalid or already-used code" };
|
|
126
|
+
if (!pair || pair.status !== "pending" || now > pair.expires) {
|
|
127
|
+
return { code: 410, error: "code expired or already used" };
|
|
128
|
+
}
|
|
129
|
+
pair.status = "completed";
|
|
130
|
+
return { code: 200 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pair = { status: "pending", expires: 100 };
|
|
134
|
+
assert(pairCodeModel(pair, true, 10).code === 200, "first pair-complete succeeds");
|
|
135
|
+
assert(pairCodeModel(pair, true, 20).code === 410, "pair code reuse fails");
|
|
136
|
+
assert(pairCodeModel({ status: "pending", expires: 100 }, true, 200).code === 410, "expired pair code fails");
|
|
137
|
+
assert(pairCodeModel(null, false, 10).code === 404, "unknown pair code fails");
|
|
138
|
+
|
|
139
|
+
console.log("crc pair relink audit and rotation checks passed");
|
|
@@ -415,6 +415,9 @@ async function followPairNextIfPresent(approveResponse, identity) {
|
|
|
415
415
|
}
|
|
416
416
|
sessionStorage.setItem('wip_api_key', identity.apiKey);
|
|
417
417
|
if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
|
|
418
|
+
if (PAIR_NEXT_REGEX.test(next) && identity.codex_pair_presence_token) {
|
|
419
|
+
sessionStorage.setItem('wip_codex_pair_presence_token', identity.codex_pair_presence_token);
|
|
420
|
+
}
|
|
418
421
|
location.replace(next);
|
|
419
422
|
return true;
|
|
420
423
|
}
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
<!-- View 3: success -->
|
|
75
75
|
<div id="success-view" style="display: none;">
|
|
76
76
|
<h1>Paired.</h1>
|
|
77
|
-
<div class="sub">Your laptop will pick this up in a few seconds.</div>
|
|
77
|
+
<div id="success-sub" class="sub">Your laptop will pick this up in a few seconds.</div>
|
|
78
78
|
<div class="footer">You can close this tab.</div>
|
|
79
79
|
</div>
|
|
80
80
|
</div>
|
|
@@ -86,6 +86,7 @@ var PAIR_CODE_REGEX = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
|
86
86
|
|
|
87
87
|
function getApiKey() { return sessionStorage.getItem('wip_api_key'); }
|
|
88
88
|
function getHandle() { return sessionStorage.getItem('wip_handle') || '(unknown)'; }
|
|
89
|
+
function getPairPresenceToken() { return sessionStorage.getItem('wip_codex_pair_presence_token'); }
|
|
89
90
|
|
|
90
91
|
function setStatus(elId, msg, kind) {
|
|
91
92
|
var el = document.getElementById(elId);
|
|
@@ -104,7 +105,9 @@ function readCodeFromPath() {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// POST the code to /api/codex-relay/pair-complete using the api_key
|
|
107
|
-
// from sessionStorage as Bearer token.
|
|
108
|
+
// from sessionStorage as Bearer token. Daemon key pairing also sends the
|
|
109
|
+
// short-lived token minted by the passkey verify step, so a stale ck- token
|
|
110
|
+
// alone cannot replace a daemon. On success, show the success view.
|
|
108
111
|
async function submitPair(code, statusElId) {
|
|
109
112
|
if (!PAIR_CODE_REGEX.test(code)) {
|
|
110
113
|
setStatus(statusElId, 'That does not look like a valid code.', 'error');
|
|
@@ -118,7 +121,10 @@ async function submitPair(code, statusElId) {
|
|
|
118
121
|
'Content-Type': 'application/json',
|
|
119
122
|
'Authorization': 'Bearer ' + getApiKey(),
|
|
120
123
|
},
|
|
121
|
-
body: JSON.stringify({
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
code: code,
|
|
126
|
+
codex_pair_presence_token: getPairPresenceToken(),
|
|
127
|
+
}),
|
|
122
128
|
});
|
|
123
129
|
var data = await res.json();
|
|
124
130
|
if (!res.ok) {
|
|
@@ -127,9 +133,21 @@ async function submitPair(code, statusElId) {
|
|
|
127
133
|
if (res.status === 410 || res.status === 404) {
|
|
128
134
|
msg += ' Run codex-daemon link again on your laptop to get a fresh code.';
|
|
129
135
|
}
|
|
136
|
+
if (res.status === 403 && data && data.error === 'fresh_presence_required') {
|
|
137
|
+
msg = 'Sign in again with your passkey to relink this daemon.';
|
|
138
|
+
sessionStorage.removeItem('wip_api_key');
|
|
139
|
+
sessionStorage.removeItem('wip_codex_pair_presence_token');
|
|
140
|
+
setTimeout(function() {
|
|
141
|
+
location.replace('/login?next=' + encodeURIComponent('/pair/' + code));
|
|
142
|
+
}, 1200);
|
|
143
|
+
}
|
|
130
144
|
setStatus(statusElId, msg, 'error');
|
|
131
145
|
return false;
|
|
132
146
|
}
|
|
147
|
+
sessionStorage.removeItem('wip_codex_pair_presence_token');
|
|
148
|
+
document.getElementById('success-sub').textContent = data.replaced_daemon_key
|
|
149
|
+
? 'Remote Control relinked this laptop. Existing browser control tabs need to reconnect.'
|
|
150
|
+
: 'Your laptop will pick this up in a few seconds.';
|
|
133
151
|
document.getElementById('confirm-view').style.display = 'none';
|
|
134
152
|
document.getElementById('manual-view').style.display = 'none';
|
|
135
153
|
document.getElementById('success-view').style.display = 'block';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
export function normalizeCodexCryptoVersions(versions) {
|
|
2
4
|
const out = Array.isArray(versions)
|
|
3
5
|
? versions.filter((v) => typeof v === "string" && v.length > 0 && v.length <= 32).slice(0, 8)
|
|
@@ -5,6 +7,11 @@ export function normalizeCodexCryptoVersions(versions) {
|
|
|
5
7
|
return out.length ? out : ["e2ee-v1"];
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
export function codexDaemonPubkeyFingerprint(pubkey) {
|
|
11
|
+
if (typeof pubkey !== "string" || !pubkey) return null;
|
|
12
|
+
return "sha256:" + createHash("sha256").update(pubkey).digest("base64url").slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export function buildCodexBootstrapPayload({ identity, threadId, daemonOnline, daemonKey }) {
|
|
9
16
|
return {
|
|
10
17
|
handle: identity.handle,
|
|
@@ -24,6 +31,7 @@ export function createCodexDaemonPubkeyRegistry({
|
|
|
24
31
|
logger = console,
|
|
25
32
|
} = {}) {
|
|
26
33
|
const pubkeys = new Map();
|
|
34
|
+
const auditLog = [];
|
|
27
35
|
|
|
28
36
|
async function ensureStore() {
|
|
29
37
|
if (!usePrisma) return;
|
|
@@ -37,6 +45,21 @@ export function createCodexDaemonPubkeyRegistry({
|
|
|
37
45
|
`);
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
async function ensureAuditStore() {
|
|
49
|
+
if (!usePrisma) return;
|
|
50
|
+
await prisma.$executeRawUnsafe(`
|
|
51
|
+
CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
tenant_id TEXT NOT NULL,
|
|
54
|
+
source TEXT NOT NULL,
|
|
55
|
+
old_pubkey_fingerprint TEXT,
|
|
56
|
+
new_pubkey_fingerprint TEXT,
|
|
57
|
+
replaced BOOLEAN NOT NULL,
|
|
58
|
+
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
59
|
+
)
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
async function loadFromDb() {
|
|
41
64
|
if (!usePrisma) return;
|
|
42
65
|
try {
|
|
@@ -79,9 +102,39 @@ export function createCodexDaemonPubkeyRegistry({
|
|
|
79
102
|
);
|
|
80
103
|
}
|
|
81
104
|
|
|
105
|
+
async function recordAudit(agentId, previousPubkey, nextPubkey, source, registeredAt) {
|
|
106
|
+
const oldFingerprint = codexDaemonPubkeyFingerprint(previousPubkey);
|
|
107
|
+
const newFingerprint = codexDaemonPubkeyFingerprint(nextPubkey);
|
|
108
|
+
const replaced = !!(previousPubkey && previousPubkey !== nextPubkey);
|
|
109
|
+
const entry = {
|
|
110
|
+
tenant_id: agentId,
|
|
111
|
+
source,
|
|
112
|
+
old_pubkey_fingerprint: oldFingerprint,
|
|
113
|
+
new_pubkey_fingerprint: newFingerprint,
|
|
114
|
+
replaced,
|
|
115
|
+
registered_at: registeredAt,
|
|
116
|
+
};
|
|
117
|
+
auditLog.push(entry);
|
|
118
|
+
if (!usePrisma) return;
|
|
119
|
+
await ensureAuditStore();
|
|
120
|
+
await prisma.$executeRawUnsafe(
|
|
121
|
+
`INSERT INTO codex_daemon_e2ee_key_audit
|
|
122
|
+
(id, tenant_id, source, old_pubkey_fingerprint, new_pubkey_fingerprint, replaced, registered_at)
|
|
123
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)`,
|
|
124
|
+
randomUUID(),
|
|
125
|
+
agentId,
|
|
126
|
+
source,
|
|
127
|
+
oldFingerprint,
|
|
128
|
+
newFingerprint,
|
|
129
|
+
replaced,
|
|
130
|
+
registeredAt,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
function register(agentId, pubkey, cryptoVersions, source) {
|
|
83
135
|
if (typeof agentId !== "string" || !agentId) return Promise.resolve(false);
|
|
84
136
|
if (typeof pubkey !== "string" || !pubkey || pubkey.length > 1024) return Promise.resolve(false);
|
|
137
|
+
const previous = pubkeys.get(agentId) || null;
|
|
85
138
|
const normalizedVersions = normalizeCodexCryptoVersions(cryptoVersions);
|
|
86
139
|
const registeredAt = new Date().toISOString();
|
|
87
140
|
pubkeys.set(agentId, {
|
|
@@ -91,7 +144,13 @@ export function createCodexDaemonPubkeyRegistry({
|
|
|
91
144
|
});
|
|
92
145
|
logger.log("codex-relay: registered E2EE pubkey for " + agentId + " via " + source);
|
|
93
146
|
return persist(agentId, pubkey, normalizedVersions)
|
|
94
|
-
.then(() =>
|
|
147
|
+
.then(() => recordAudit(agentId, previous?.pubkey || null, pubkey, source, registeredAt))
|
|
148
|
+
.then(() => ({
|
|
149
|
+
registered: true,
|
|
150
|
+
replaced: !!(previous?.pubkey && previous.pubkey !== pubkey),
|
|
151
|
+
old_fingerprint: codexDaemonPubkeyFingerprint(previous?.pubkey || null),
|
|
152
|
+
new_fingerprint: codexDaemonPubkeyFingerprint(pubkey),
|
|
153
|
+
}))
|
|
95
154
|
.catch((err) => {
|
|
96
155
|
logger.error("codex-relay: failed to persist E2EE pubkey for " + agentId + ":", err.message);
|
|
97
156
|
if (!devMode) throw err;
|
|
@@ -101,7 +160,9 @@ export function createCodexDaemonPubkeyRegistry({
|
|
|
101
160
|
|
|
102
161
|
return {
|
|
103
162
|
pubkeys,
|
|
163
|
+
auditLog,
|
|
104
164
|
ensureStore,
|
|
165
|
+
ensureAuditStore,
|
|
105
166
|
loadFromDb,
|
|
106
167
|
register,
|
|
107
168
|
get(agentId) {
|
|
@@ -775,7 +775,14 @@ async function handleRegisterVerify(req, res) {
|
|
|
775
775
|
|
|
776
776
|
console.log("WebAuthn: registered passkey for tenant '" + agentId + "' handle '" + credentialLabel + "' (credId: " + cred.id.slice(0, 16) + "...)");
|
|
777
777
|
|
|
778
|
-
json(res, 200, {
|
|
778
|
+
json(res, 200, {
|
|
779
|
+
success: true,
|
|
780
|
+
agentId: credentialLabel,
|
|
781
|
+
tenantId: agentId,
|
|
782
|
+
apiKey,
|
|
783
|
+
credentialLabel,
|
|
784
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(agentId),
|
|
785
|
+
});
|
|
779
786
|
}
|
|
780
787
|
|
|
781
788
|
// POST /webauthn/auth-options
|
|
@@ -907,7 +914,14 @@ async function handleAuthVerify(req, res) {
|
|
|
907
914
|
|
|
908
915
|
console.log("WebAuthn: authenticated tenant '" + entry.agentId + "' handle '" + credentialLabel + "'");
|
|
909
916
|
|
|
910
|
-
json(res, 200, {
|
|
917
|
+
json(res, 200, {
|
|
918
|
+
success: true,
|
|
919
|
+
agentId: credentialLabel,
|
|
920
|
+
tenantId: entry.agentId,
|
|
921
|
+
apiKey: entry.apiKey,
|
|
922
|
+
credentialLabel,
|
|
923
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId),
|
|
924
|
+
});
|
|
911
925
|
}
|
|
912
926
|
|
|
913
927
|
// ---------- Page handlers ----------
|
|
@@ -2597,14 +2611,17 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2597
2611
|
//
|
|
2598
2612
|
// In-memory state. Pairing codes: 6-char, 5-min TTL. Daemons indexed by
|
|
2599
2613
|
// immutable tenant id (one daemon per tenant; new daemon kicks the old one). Web clients
|
|
2600
|
-
// indexed by `tenantId:threadId`.
|
|
2601
|
-
// the daemon and
|
|
2602
|
-
//
|
|
2614
|
+
// indexed by `tenantId:threadId`. The server is a transport relay between
|
|
2615
|
+
// the daemon and matching web client(s). The relay injects the route thread
|
|
2616
|
+
// into the E2EE handshake, and the daemon enforces that bound route after
|
|
2617
|
+
// decrypting session commands.
|
|
2603
2618
|
|
|
2604
2619
|
const CODEX_PAIR_EXPIRY_MS = 5 * 60 * 1000;
|
|
2620
|
+
const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;
|
|
2605
2621
|
const CODEX_PAIR_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2606
2622
|
const codexPairings = {}; // pairing_id -> { code, status, expires, poll_token, poll_token_used?, daemon_info, apiKey?, agentId?, handle?, daemon_public_key?, crypto_versions? }
|
|
2607
2623
|
const codexPairingByCode = {}; // code -> pairing_id (only while pending)
|
|
2624
|
+
const codexPairPresenceTokens = new Map(); // token -> { agentId, expires, used }
|
|
2608
2625
|
const codexDaemons = new Map(); // agentId -> ws
|
|
2609
2626
|
const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>
|
|
2610
2627
|
const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }
|
|
@@ -2689,6 +2706,25 @@ function removeCodexE2eeRoutesForWeb(agentId, threadId, ws) {
|
|
|
2689
2706
|
}
|
|
2690
2707
|
}
|
|
2691
2708
|
|
|
2709
|
+
function invalidateCodexBrowserSessionsForAgent(agentId, reason) {
|
|
2710
|
+
const prefix = agentId + ":";
|
|
2711
|
+
let closed = 0;
|
|
2712
|
+
for (const routeKey of [...codexE2eeSessionRoutes.keys()]) {
|
|
2713
|
+
if (routeKey.startsWith(prefix)) codexE2eeSessionRoutes.delete(routeKey);
|
|
2714
|
+
}
|
|
2715
|
+
for (const [webKey, clients] of [...codexWebClients]) {
|
|
2716
|
+
if (!webKey.startsWith(prefix)) continue;
|
|
2717
|
+
for (const webWs of clients) {
|
|
2718
|
+
if (webWs.readyState === webWs.OPEN) {
|
|
2719
|
+
closed += 1;
|
|
2720
|
+
try { webWs.close(4001, reason); } catch {}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
codexWebClients.delete(webKey);
|
|
2724
|
+
}
|
|
2725
|
+
return closed;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2692
2728
|
function generateCodexPairingCode() {
|
|
2693
2729
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
2694
2730
|
let code = "";
|
|
@@ -2705,6 +2741,34 @@ function generateCodexPairPollToken() {
|
|
|
2705
2741
|
return "ppt_" + randomBytes(32).toString("base64url");
|
|
2706
2742
|
}
|
|
2707
2743
|
|
|
2744
|
+
function cleanupCodexPairPresenceTokens() {
|
|
2745
|
+
const now = Date.now();
|
|
2746
|
+
for (const [token, entry] of codexPairPresenceTokens) {
|
|
2747
|
+
if (!entry || entry.used || now > entry.expires) codexPairPresenceTokens.delete(token);
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function generateCodexPairPresenceToken(agentId) {
|
|
2752
|
+
cleanupCodexPairPresenceTokens();
|
|
2753
|
+
if (typeof agentId !== "string" || !agentId) return null;
|
|
2754
|
+
const token = "cpt_" + randomBytes(32).toString("base64url");
|
|
2755
|
+
codexPairPresenceTokens.set(token, {
|
|
2756
|
+
agentId,
|
|
2757
|
+
expires: Date.now() + CODEX_PAIR_PRESENCE_TTL_MS,
|
|
2758
|
+
used: false,
|
|
2759
|
+
});
|
|
2760
|
+
return token;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function consumeCodexPairPresenceToken(token, agentId) {
|
|
2764
|
+
cleanupCodexPairPresenceTokens();
|
|
2765
|
+
const entry = codexPairPresenceTokens.get(token);
|
|
2766
|
+
if (!entry || entry.used || entry.agentId !== agentId || Date.now() > entry.expires) return false;
|
|
2767
|
+
entry.used = true;
|
|
2768
|
+
codexPairPresenceTokens.delete(token);
|
|
2769
|
+
return true;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2708
2772
|
function getBearerToken(req) {
|
|
2709
2773
|
const auth = req.headers["authorization"];
|
|
2710
2774
|
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) return null;
|
|
@@ -2769,7 +2833,12 @@ function handleCodexPairStatus(req, res, pairingId) {
|
|
|
2769
2833
|
}
|
|
2770
2834
|
if (p.status === "completed") {
|
|
2771
2835
|
p.poll_token_used = true;
|
|
2772
|
-
json(res, 200, {
|
|
2836
|
+
json(res, 200, {
|
|
2837
|
+
status: "completed",
|
|
2838
|
+
api_key: p.apiKey,
|
|
2839
|
+
handle: p.handle || p.agentId,
|
|
2840
|
+
replaced_daemon_key: !!p.replaced_daemon_key,
|
|
2841
|
+
});
|
|
2773
2842
|
} else {
|
|
2774
2843
|
json(res, 200, { status: p.status });
|
|
2775
2844
|
}
|
|
@@ -2797,19 +2866,44 @@ async function handleCodexPairComplete(req, res) {
|
|
|
2797
2866
|
json(res, 410, { error: "code expired or already used" });
|
|
2798
2867
|
return;
|
|
2799
2868
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
p.
|
|
2869
|
+
const pairPresenceToken = body && typeof body.codex_pair_presence_token === "string"
|
|
2870
|
+
? body.codex_pair_presence_token
|
|
2871
|
+
: "";
|
|
2872
|
+
if (p.daemon_public_key && !consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)) {
|
|
2873
|
+
json(res, 403, {
|
|
2874
|
+
error: "fresh_presence_required",
|
|
2875
|
+
error_description: "Pairing this daemon requires a fresh passkey confirmation. Sign in again from the pair page.",
|
|
2876
|
+
});
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2804
2879
|
// Phase 2.5: register the daemon's E2EE public key against the
|
|
2805
2880
|
// authenticated immutable tenant id. The display handle is returned
|
|
2806
2881
|
// as metadata only.
|
|
2882
|
+
let daemonKeyResult = null;
|
|
2807
2883
|
if (p.daemon_public_key) {
|
|
2808
|
-
await codexDaemonPubkeyRegistry.register(identity.agentId, p.daemon_public_key, p.crypto_versions, "pair-complete");
|
|
2884
|
+
daemonKeyResult = await codexDaemonPubkeyRegistry.register(identity.agentId, p.daemon_public_key, p.crypto_versions, "pair-complete");
|
|
2885
|
+
if (daemonKeyResult?.replaced) {
|
|
2886
|
+
const closed = invalidateCodexBrowserSessionsForAgent(identity.agentId, "daemon key replaced");
|
|
2887
|
+
console.log(
|
|
2888
|
+
"codex-relay: replaced daemon E2EE key for tenant " + identity.agentId
|
|
2889
|
+
+ " old=" + daemonKeyResult.old_fingerprint
|
|
2890
|
+
+ " new=" + daemonKeyResult.new_fingerprint
|
|
2891
|
+
+ " closed_browser_sessions=" + closed
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2809
2894
|
}
|
|
2895
|
+
p.status = "completed";
|
|
2896
|
+
p.apiKey = identity.apiKey;
|
|
2897
|
+
p.agentId = identity.agentId;
|
|
2898
|
+
p.handle = identity.handle;
|
|
2899
|
+
p.replaced_daemon_key = !!daemonKeyResult?.replaced;
|
|
2810
2900
|
delete codexPairingByCode[code];
|
|
2811
2901
|
console.log("codex-relay: paired daemon for tenant " + identity.agentId + " handle " + identity.handle);
|
|
2812
|
-
json(res, 200, {
|
|
2902
|
+
json(res, 200, {
|
|
2903
|
+
ok: true,
|
|
2904
|
+
handle: identity.handle,
|
|
2905
|
+
replaced_daemon_key: !!daemonKeyResult?.replaced,
|
|
2906
|
+
});
|
|
2813
2907
|
}
|
|
2814
2908
|
|
|
2815
2909
|
function handleCodexRelayState(req, res) {
|