@minhspark/codex-mcp-bridge 1.16.0 → 1.16.1
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/CHANGELOG.md +12 -0
- package/README.md +212 -574
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +163 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +33 -0
- package/scripts/render-repo-analytics.mjs +50 -0
- package/scripts/usage-summary.sql +19 -0
- package/src/claude-bridge-launcher.mjs +2 -1
- package/src/claude-bridge.mjs +102 -16
- package/src/claude-session-router.mjs +16 -0
- package/src/codex-bridge-launcher.mjs +2 -1
- package/src/codex-native-response.mjs +333 -0
- package/src/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -18
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +88 -11
- package/src/reply-forwarder.mjs +10 -3
- package/src/security-policy.mjs +4 -0
- package/src/telemetry-config.mjs +2 -0
- package/src/telemetry.mjs +149 -0
- package/src/thread-delivery.mjs +89 -11
- package/src/turn.mjs +35 -3
- package/src/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
package/src/turn.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
1
3
|
const TERMINAL_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
2
4
|
|
|
3
5
|
function summarizeItem(item) {
|
|
4
6
|
switch (item?.type) {
|
|
5
7
|
case "agentMessage":
|
|
6
|
-
return { kind: "agentMessage", text: item.text ?? "" };
|
|
8
|
+
return { kind: "agentMessage", id: item.id ?? null, text: item.text ?? "" };
|
|
7
9
|
case "commandExecution":
|
|
8
10
|
return {
|
|
9
11
|
kind: "command",
|
|
@@ -32,9 +34,11 @@ function summarizeItem(item) {
|
|
|
32
34
|
*/
|
|
33
35
|
export async function runTurn(client, { threadId, input, timeoutMs = 240000, turnOverrides = {} }) {
|
|
34
36
|
const messages = [];
|
|
37
|
+
const assistantItems = [];
|
|
35
38
|
const activity = [];
|
|
36
39
|
const errors = [];
|
|
37
40
|
const buffered = [];
|
|
41
|
+
const completedItems = new Map();
|
|
38
42
|
let turnId = null;
|
|
39
43
|
let settled = false;
|
|
40
44
|
let resolveDone;
|
|
@@ -56,6 +60,7 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
56
60
|
const process = (msg) => {
|
|
57
61
|
if (settled) return;
|
|
58
62
|
const params = msg.params ?? {};
|
|
63
|
+
if (params.threadId !== undefined && params.threadId !== threadId) return;
|
|
59
64
|
if (turnId && params.turnId && params.turnId !== turnId) return;
|
|
60
65
|
|
|
61
66
|
switch (msg.method) {
|
|
@@ -65,10 +70,25 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
65
70
|
return;
|
|
66
71
|
}
|
|
67
72
|
case "item/completed": {
|
|
73
|
+
if (!turnId || params.turnId !== turnId) return;
|
|
68
74
|
const summary = summarizeItem(params.item);
|
|
69
75
|
if (!summary) return;
|
|
76
|
+
const itemId = params.item?.id;
|
|
77
|
+
if (typeof itemId !== "string" || !itemId.trim()) return;
|
|
78
|
+
const fingerprint = JSON.stringify(summary);
|
|
79
|
+
if (completedItems.has(itemId)) {
|
|
80
|
+
if (completedItems.get(itemId) !== fingerprint && !settled) {
|
|
81
|
+
settled = true;
|
|
82
|
+
resolveDone({ status: "failed", error: { message: `Conflicting duplicate item ${itemId}` } });
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
completedItems.set(itemId, fingerprint);
|
|
70
87
|
if (summary.kind === "agentMessage") {
|
|
71
|
-
if (summary.text.trim())
|
|
88
|
+
if (summary.text.trim()) {
|
|
89
|
+
messages.push(summary.text);
|
|
90
|
+
assistantItems.push({ id: itemId, text: summary.text });
|
|
91
|
+
}
|
|
72
92
|
} else {
|
|
73
93
|
activity.push(summary);
|
|
74
94
|
}
|
|
@@ -86,6 +106,9 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
86
106
|
const turn = params.turn ?? {};
|
|
87
107
|
if (turnId && turn.id && turn.id !== turnId) return;
|
|
88
108
|
if (!TERMINAL_STATUSES.has(turn.status)) return;
|
|
109
|
+
if (Array.isArray(turn.items)) {
|
|
110
|
+
for (const item of turn.items) process({ method: "item/completed", params: { threadId, turnId, item } });
|
|
111
|
+
}
|
|
89
112
|
if (settled) return;
|
|
90
113
|
settled = true;
|
|
91
114
|
resolveDone({ status: turn.status, error: turn.error ?? null, durationMs: turn.durationMs ?? null });
|
|
@@ -135,18 +158,27 @@ export async function runTurn(client, { threadId, input, timeoutMs = 240000, tur
|
|
|
135
158
|
throw new Error("Codex app-server did not return a turn id for turn/start");
|
|
136
159
|
}
|
|
137
160
|
for (const msg of buffered.splice(0)) process(msg);
|
|
161
|
+
if (Array.isArray(start.started?.turn?.items)) {
|
|
162
|
+
for (const item of start.started.turn.items) process({ method: "item/completed", params: { threadId, turnId, item } });
|
|
163
|
+
}
|
|
138
164
|
if (TERMINAL_STATUSES.has(start.started?.turn?.status)) {
|
|
139
165
|
process({ method: "turn/completed", params: { turn: start.started.turn } });
|
|
140
166
|
}
|
|
141
167
|
outcome = await done;
|
|
142
168
|
}
|
|
169
|
+
const completedReply = outcome.status === "completed";
|
|
170
|
+
const text = completedReply ? messages.join("\n\n") : "";
|
|
171
|
+
const returnedItems = completedReply ? assistantItems : [];
|
|
143
172
|
return {
|
|
144
173
|
threadId,
|
|
145
174
|
turnId,
|
|
146
175
|
status: outcome.status,
|
|
147
176
|
error: outcome.error,
|
|
148
177
|
durationMs: outcome.durationMs ?? null,
|
|
149
|
-
text
|
|
178
|
+
text,
|
|
179
|
+
responseStatus: completedReply ? (returnedItems.length ? "completed" : "completed_no_reply") : "unavailable",
|
|
180
|
+
assistantItems: returnedItems,
|
|
181
|
+
replySha256: completedReply ? crypto.createHash("sha256").update(text.replace(/\r\n?/g, "\n").normalize("NFC"), "utf8").digest("hex") : null,
|
|
150
182
|
activity,
|
|
151
183
|
errors,
|
|
152
184
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { IS_WINDOWS } from "./platform.mjs";
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCallback);
|
|
7
|
+
const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
8
|
+
|
|
9
|
+
export function bridgePipeName(socketPath) {
|
|
10
|
+
const prefix = "\\\\.\\pipe\\";
|
|
11
|
+
if (typeof socketPath !== "string" || !socketPath.startsWith(prefix)) throw new Error("A Windows ACL may only protect a named pipe");
|
|
12
|
+
return socketPath.slice(prefix.length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function protectCurrentUserPipe(socketPath, { pid = process.pid, run = execFile } = {}) {
|
|
16
|
+
if (!IS_WINDOWS) return { skipped: true };
|
|
17
|
+
const name = bridgePipeName(socketPath);
|
|
18
|
+
const script = fileURLToPath(new URL("./windows-pipe-acl.ps1", import.meta.url));
|
|
19
|
+
const { stdout } = await run(powershell, ["-NoProfile", "-NonInteractive", "-File", script, "-PipeName", name, "-ServerPid", String(pid)], { windowsHide: true, timeout: 15000 });
|
|
20
|
+
let result;
|
|
21
|
+
try { result = JSON.parse(stdout.trim()); } catch { throw new Error("Windows pipe ACL helper returned invalid readback"); }
|
|
22
|
+
if (result?.serverMatches !== true || result?.serverUserMatches !== true || result?.ownerMatches !== true || result?.protected !== true || result?.aceCount !== 1
|
|
23
|
+
|| result?.rights !== 2032031 || result?.anonymousDenied !== true || result?.readError !== 5 || result?.duplexError !== 5) {
|
|
24
|
+
throw new Error("Windows pipe ACL readback did not prove exact current-user FullControl and anonymous denial");
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
param([Parameter(Mandatory)][string]$PipeName,[Parameter(Mandatory)][uint32]$ServerPid)
|
|
2
|
+
$ErrorActionPreference='Stop'
|
|
3
|
+
if($PipeName -notmatch '^LOCAL\\(?:codex-native-relay|codex-bridge|cc-msg)-[A-Za-z0-9_.-]+$'){throw 'Refusing a pipe outside the bridge-owned namespace.'}
|
|
4
|
+
Add-Type @'
|
|
5
|
+
using System;
|
|
6
|
+
using System.ComponentModel;
|
|
7
|
+
using System.Runtime.InteropServices;
|
|
8
|
+
using System.Security.Principal;
|
|
9
|
+
using Microsoft.Win32.SafeHandles;
|
|
10
|
+
public sealed class BridgeServerIdentity : IDisposable {
|
|
11
|
+
[DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
|
12
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
|
13
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
|
|
14
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr buffer, uint length, out uint required);
|
|
15
|
+
IntPtr process;
|
|
16
|
+
public string User { get; private set; }
|
|
17
|
+
public string Owner { get; private set; }
|
|
18
|
+
static string ReadSid(IntPtr token, int kind) {
|
|
19
|
+
uint required;
|
|
20
|
+
if(GetTokenInformation(token, kind, IntPtr.Zero, 0, out required) || Marshal.GetLastWin32Error()!=122 || required<IntPtr.Size || required>65536) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
21
|
+
IntPtr buffer=Marshal.AllocHGlobal((int)required);
|
|
22
|
+
try {
|
|
23
|
+
if(!GetTokenInformation(token, kind, buffer, required, out required)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
24
|
+
return new SecurityIdentifier(Marshal.ReadIntPtr(buffer)).Value;
|
|
25
|
+
} finally { Marshal.FreeHGlobal(buffer); }
|
|
26
|
+
}
|
|
27
|
+
public BridgeServerIdentity(uint pid) {
|
|
28
|
+
process=OpenProcess(0x1000, false, pid);
|
|
29
|
+
if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
30
|
+
IntPtr token=IntPtr.Zero;
|
|
31
|
+
try {
|
|
32
|
+
if(!OpenProcessToken(process, 8, out token)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
33
|
+
User=ReadSid(token, 1);
|
|
34
|
+
Owner=ReadSid(token, 4);
|
|
35
|
+
} catch { Dispose(); throw; }
|
|
36
|
+
finally { if(token!=IntPtr.Zero) CloseHandle(token); }
|
|
37
|
+
}
|
|
38
|
+
public void Dispose() { if(process!=IntPtr.Zero) { CloseHandle(process); process=IntPtr.Zero; } }
|
|
39
|
+
}
|
|
40
|
+
public static class BridgePipeAcl {
|
|
41
|
+
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
|
|
42
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool ImpersonateAnonymousToken(IntPtr thread);
|
|
43
|
+
[DllImport("advapi32.dll", SetLastError=true)] static extern bool RevertToSelf();
|
|
44
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint pid);
|
|
45
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFile(string name,uint access,uint share,IntPtr sa,uint creation,uint flags,IntPtr template);
|
|
46
|
+
public static int AttemptAnonymous(string name,uint access) {
|
|
47
|
+
if(!ImpersonateAnonymousToken(GetCurrentThread())) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
48
|
+
try {
|
|
49
|
+
using(var handle=CreateFile(name,access,0,IntPtr.Zero,3,0,IntPtr.Zero)) {
|
|
50
|
+
int error=Marshal.GetLastWin32Error();
|
|
51
|
+
return handle.IsInvalid ? error : 0;
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
if(!RevertToSelf()) Environment.FailFast("Could not end anonymous pipe ACL verification.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
'@
|
|
59
|
+
$pipe=New-Object System.IO.Pipes.NamedPipeClientStream('.', $PipeName, [System.IO.Pipes.PipeAccessRights]::FullControl, [System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::Identification, [System.IO.HandleInheritability]::None)
|
|
60
|
+
$serverIdentity=$null
|
|
61
|
+
try {
|
|
62
|
+
$pipe.Connect(3000)
|
|
63
|
+
[uint32]$actualPid=0
|
|
64
|
+
if(-not [BridgePipeAcl]::GetNamedPipeServerProcessId($pipe.SafePipeHandle,[ref]$actualPid) -or $actualPid -ne $ServerPid){throw 'Pipe server identity mismatch.'}
|
|
65
|
+
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
66
|
+
$serverIdentity=New-Object BridgeServerIdentity($actualPid)
|
|
67
|
+
if($serverIdentity.User -ne $sid.Value){throw 'Pipe server token user is not current user.'}
|
|
68
|
+
$before=$pipe.GetAccessControl()
|
|
69
|
+
$owner=$before.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
70
|
+
if($owner -ne $sid.Value -and $owner -ne $serverIdentity.Owner){throw 'Pipe owner does not match the verified server token.'}
|
|
71
|
+
$acl=New-Object System.IO.Pipes.PipeSecurity
|
|
72
|
+
$acl.SetOwner($sid)
|
|
73
|
+
$acl.SetAccessRuleProtection($true,$false)
|
|
74
|
+
$acl.AddAccessRule((New-Object System.IO.Pipes.PipeAccessRule($sid,[System.IO.Pipes.PipeAccessRights]::FullControl,[System.Security.AccessControl.AccessControlType]::Allow)))
|
|
75
|
+
$pipe.SetAccessControl($acl)
|
|
76
|
+
if(-not [BridgePipeAcl]::GetNamedPipeServerProcessId($pipe.SafePipeHandle,[ref]$actualPid) -or $actualPid -ne $ServerPid){throw 'Pipe server identity changed.'}
|
|
77
|
+
$after=$pipe.GetAccessControl(); $rules=@($after.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
|
|
78
|
+
if($after.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){throw 'Restricted owner readback mismatch.'}
|
|
79
|
+
if(-not $after.AreAccessRulesProtected -or $rules.Count -ne 1 -or $rules[0].IsInherited -or $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].AccessControlType -ne 'Allow' -or $rules[0].PipeAccessRights -ne [System.IO.Pipes.PipeAccessRights]::FullControl){throw 'Restricted DACL readback mismatch.'}
|
|
80
|
+
$nativeName='\\.\pipe\'+$PipeName
|
|
81
|
+
$readError=[BridgePipeAcl]::AttemptAnonymous($nativeName,[uint32]2147483648)
|
|
82
|
+
$duplexError=[BridgePipeAcl]::AttemptAnonymous($nativeName,[uint32]3221225472)
|
|
83
|
+
if($readError -ne 5 -or $duplexError -ne 5){throw "Anonymous pipe access was not denied: read=$readError duplex=$duplexError"}
|
|
84
|
+
[pscustomobject]@{serverMatches=$true;serverUserMatches=$true;ownerMatches=$true;protected=$true;aceCount=1;rights=[int]$rules[0].PipeAccessRights;anonymousDenied=$true;readError=$readError;duplexError=$duplexError}|ConvertTo-Json -Compress
|
|
85
|
+
} finally {if($null -ne $serverIdentity){$serverIdentity.Dispose()};$pipe.Dispose()}
|