@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/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()) messages.push(summary.text);
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: messages.join("\n\n").trim(),
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()}