diff --git a/flake.nix b/flake.nix index 977310de8..97e3c93d3 100644 --- a/flake.nix +++ b/flake.nix @@ -134,10 +134,10 @@ chatgptDmg = pkgs.fetchurl { url = "https://persistent.oaistatic.com/codex-app-prod/ChatGPT.dmg"; - hash = "sha256-7P+2/AIXozb8zrh1jwpUzDoGSBFWGKhr3czByxj7SKQ="; + hash = "sha256-QF22NyzBPHnSkcIaMRWXcmMhXC28uoOMdsk1JC1GxKA="; }; - chatgptVersion = "26.810.52044"; + chatgptVersion = "26.814.41407"; electronVersion = "42.3.0"; electronPlatform = { @@ -248,23 +248,30 @@ }; browserUseNodeReplRuntime = pkgs.fetchurl { - url = "https://persistent.oaistatic.com/codex-primary-runtime/26.426.12240/codex-primary-runtime-linux-x64-26.426.12240.tar.xz"; - hash = "sha256-21Yk6276NrZuxvbdBIjO+5ZuSWNoYqq2IJpDNsHKkMQ="; + url = "https://persistent.oaistatic.com/codex-app-prod/linux/deb/pool/main/c/chatgpt/chatgpt_26.814.41407_amd64.deb"; + hash = "sha256-BT1azpHEihcUau8Cykq7AKKx6U/9FcoBiR/YSoInyoA="; }; browserUseNodeRepl = if system == "x86_64-linux" then pkgs.stdenv.mkDerivation { pname = "codex-browser-use-node-repl"; - version = "26.426.12240"; + version = "26.814.41407"; src = browserUseNodeReplRuntime; + nativeBuildInputs = [ pkgs.binutils pkgs.xz ]; + + dontUnpack = true; dontConfigure = true; dontBuild = true; installPhase = '' runHook preInstall mkdir -p "$out/bin" - tar -xJf "$src" -C "$TMPDIR" codex-primary-runtime/dependencies/bin/node_repl - install -m 0755 "$TMPDIR/codex-primary-runtime/dependencies/bin/node_repl" "$out/bin/node_repl" + ar p "$src" data.tar.xz > "$TMPDIR/chatgpt-data.tar.xz" + tar -xJf "$TMPDIR/chatgpt-data.tar.xz" -C "$TMPDIR" ./usr/lib/chatgpt/resources/cua_node/bin/node_repl + grep -aFq 'NODE_REPL_TRUSTED_SERVICES' "$TMPDIR/usr/lib/chatgpt/resources/cua_node/bin/node_repl" + grep -aFq 'NODE_REPL_TRUSTED_RPC_ENABLED' "$TMPDIR/usr/lib/chatgpt/resources/cua_node/bin/node_repl" + grep -aFq 'nodeRepl.rpc = function rpc' "$TMPDIR/usr/lib/chatgpt/resources/cua_node/bin/node_repl" + install -m 0755 "$TMPDIR/usr/lib/chatgpt/resources/cua_node/bin/node_repl" "$out/bin/node_repl" runHook postInstall ''; } else null; @@ -1189,6 +1196,7 @@ PY nativeBuildInputs = [ pkgs.asar + pkgs.binutils pkgs.makeWrapper pkgs.patchelf ]; @@ -1241,32 +1249,20 @@ PY "$out/lib/udev/rules.d/70-codex-micro.rules" ''} + . ${sourceRoot}/scripts/lib/nix-node-repl-elf.sh + node_repl_nix_interpreter="$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)" + node_repl_nix_rpath="${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.glibc ]}" for node_repl_binary in \ "$resources_dir/node_repl" \ "$resources_dir/node_repl.chatgpt-linux-original"; do - if [ -f "$node_repl_binary" ] \ - && [ "$(dd if="$node_repl_binary" bs=1 count=4 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "7f454c46" ]; then - patchelf --set-interpreter "$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)" \ - --set-rpath "${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.glibc ]}" \ - "$node_repl_binary" + if [ -f "$node_repl_binary" ]; then + patch_node_repl_elf_for_nix \ + "$node_repl_binary" \ + "$node_repl_nix_interpreter" \ + "$node_repl_nix_rpath" fi done - if [ -f "$resources_dir/node_repl.chatgpt-linux-original" ]; then - node_repl_interpreter="$(patchelf --print-interpreter \ - "$resources_dir/node_repl.chatgpt-linux-original")" - node_repl_rpath="$(patchelf --print-rpath \ - "$resources_dir/node_repl.chatgpt-linux-original")" - case "$node_repl_interpreter" in - /nix/store/*) ;; - *) echo "node_repl backup has non-Nix interpreter: $node_repl_interpreter" >&2; exit 1 ;; - esac - case "$node_repl_rpath" in - *"/nix/store/"*) ;; - *) echo "node_repl backup has non-Nix RPATH: $node_repl_rpath" >&2; exit 1 ;; - esac - fi - ${patchNixInstalledApp "$out/opt/chatgpt"} install -Dm0644 ${sourceRoot}/assets/chatgpt-linux.png \ diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 64ecc9758..9cfd94259 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -2999,6 +2999,139 @@ recover_unhealthy_running_app() { rm -f "$APP_PID_FILE" "$WEBVIEW_PID_FILE" "$LAUNCH_ACTION_SOCKET" } +recover_stale_computer_use_sockets() { + if ! needs_cold_start; then + return 0 + fi + if [ "$LAUNCHER_LOCK_HELD" -ne 1 ]; then + echo "WARN: refusing Computer Use socket recovery without the cold-start launcher lock" >&2 + return 0 + fi + if [ -z "${XDG_RUNTIME_DIR:-}" ]; then + echo "Skipping Computer Use socket recovery because XDG_RUNTIME_DIR is unset" + return 0 + fi + + if ! python3 - "$XDG_RUNTIME_DIR" "$LAUNCH_ACTION_RUNTIME_DIR" <<'PY' +import errno +import os +import socket +import stat +import sys + +runtime_root = os.path.abspath(sys.argv[1]) +runtime_dir = os.path.abspath(sys.argv[2]) +current_uid = os.getuid() + + +def private_directory(path): + try: + metadata = os.lstat(path) + except OSError as exc: + print(f"WARN: preserving Computer Use sockets because {path} cannot be inspected: {exc}", file=sys.stderr) + return False + return ( + stat.S_ISDIR(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and metadata.st_uid == current_uid + and stat.S_IMODE(metadata.st_mode) == 0o700 + ) + + +try: + confined = os.path.commonpath((runtime_root, runtime_dir)) == runtime_root +except ValueError: + confined = False +if not confined or not private_directory(runtime_root): + print("WARN: preserving Computer Use sockets because XDG_RUNTIME_DIR is not a trusted private directory", file=sys.stderr) + sys.exit(0) + +relative_dir = os.path.relpath(runtime_dir, runtime_root) +cursor = runtime_root +if relative_dir != ".": + for component in relative_dir.split(os.sep): + if component in ("", ".", ".."): + print("WARN: preserving Computer Use sockets because the runtime path is not confined", file=sys.stderr) + sys.exit(0) + cursor = os.path.join(cursor, component) + if not private_directory(cursor): + print("WARN: preserving Computer Use sockets because the app runtime directory is not private", file=sys.stderr) + sys.exit(0) + + +def recover_socket(name): + path = os.path.join(runtime_dir, name) + if len(os.fsencode(path)) > 100: + print(f"WARN: preserving Computer Use socket with an overlong path: {name}", file=sys.stderr) + return + try: + before = os.lstat(path) + except FileNotFoundError: + return + except OSError as exc: + print(f"WARN: preserving Computer Use socket {name}: {exc}", file=sys.stderr) + return + + if not ( + stat.S_ISSOCK(before.st_mode) + and before.st_uid == current_uid + and stat.S_IMODE(before.st_mode) == 0o600 + ): + print(f"WARN: preserving untrusted Computer Use endpoint: {name}", file=sys.stderr) + return + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.15) + try: + client.connect(path) + except FileNotFoundError: + return + except OSError as exc: + if exc.errno != errno.ECONNREFUSED: + print(f"WARN: preserving Computer Use socket {name} after probe error: {exc}", file=sys.stderr) + return + else: + print(f"Preserving active Computer Use socket: {name}") + return + finally: + client.close() + + try: + after = os.lstat(path) + except FileNotFoundError: + return + except OSError as exc: + print(f"WARN: preserving Computer Use socket {name} after revalidation error: {exc}", file=sys.stderr) + return + + if not ( + after.st_dev == before.st_dev + and after.st_ino == before.st_ino + and stat.S_ISSOCK(after.st_mode) + and after.st_uid == current_uid + and stat.S_IMODE(after.st_mode) == 0o600 + ): + print(f"WARN: preserving Computer Use socket changed during recovery: {name}", file=sys.stderr) + return + + try: + os.unlink(path) + except FileNotFoundError: + return + except OSError as exc: + print(f"WARN: could not remove stale Computer Use socket {name}: {exc}", file=sys.stderr) + return + print(f"Removed stale Computer Use socket: {name}") + + +recover_socket("computer-use-authority.sock") +recover_socket("computer-use-cursor.sock") +PY + then + echo "WARN: Computer Use socket recovery failed closed; preserving existing endpoints" >&2 + fi +} + send_warm_start_launch_action() { [ "$WARM_START" -eq 1 ] || return 1 [ -S "$LAUNCH_ACTION_SOCKET" ] || return 1 @@ -4961,6 +5094,7 @@ log_phase "initial_launch_state_refreshed" recover_unhealthy_running_app prepare_launch_state_under_lock +recover_stale_computer_use_sockets if send_warm_start_launch_action "${LAUNCHER_ARGS[@]}"; then echo "Sent launch args over warm-start IPC" diff --git a/plugins/openai-bundled/plugins/computer-use/.mcp.json b/plugins/openai-bundled/plugins/computer-use/.mcp.json index 50a8166f5..d9005b62e 100644 --- a/plugins/openai-bundled/plugins/computer-use/.mcp.json +++ b/plugins/openai-bundled/plugins/computer-use/.mcp.json @@ -3,7 +3,14 @@ "computer-use": { "command": "./bin/chatgpt-computer-use-linux", "args": ["mcp"], - "cwd": "." + "cwd": ".", + "env_vars": [ + "CODEX_HOME", + "XDG_RUNTIME_DIR", + "CHATGPT_LINUX_APP_ID", + "CHATGPT_APP_ID", + "CHATGPT_LINUX_INSTANCE_ID" + ] } } } diff --git a/port-integrations/conversation-mode/patch.js b/port-integrations/conversation-mode/patch.js index 001b7437a..ad1d1e197 100644 --- a/port-integrations/conversation-mode/patch.js +++ b/port-integrations/conversation-mode/patch.js @@ -274,7 +274,7 @@ function applyDictationEndpointPatch(source) { const recorderCreationPattern = /let ([A-Za-z_$][\w$]*)=new MediaRecorder\(([A-Za-z_$][\w$]*)\);([A-Za-z_$][\w$]*)\.current=\1;let ([A-Za-z_$][\w$]*)=/gu; const recorderStartPattern = - /([A-Za-z_$][\w$]*)\.onstop=\(\)=>\{([A-Za-z_$][\w$]*)\(\)\},([A-Za-z_$][\w$]*)==null\?\1\.start\(\):\1\.start\(([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)\.performance\.mark\(`recording_started`\),([A-Za-z_$][\w$]*)\(!0\)/gu; + /([A-Za-z_$][\w$]*)\.onstop=\(\)=>\{([A-Za-z_$][\w$]*)\(\)\},([A-Za-z_$][\w$]*)==null\?\1\.start\(\):\1\.start\(([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)\(!0\),([A-Za-z_$][\w$]*)\.performance\.mark\(`recording_started`\),([A-Za-z_$][\w$]*)\(!0\)/gu; const transcriptPattern = /([A-Za-z_$][\w$]*)\.length>0&&\(([A-Za-z_$][\w$]*)==null\?([A-Za-z_$][\w$]*)\.getInstance\(\)\.dispatchMessage\(`global-dictation-record-history-item`,\{text:\1\}\):\2\.setTranscript\(\1\),([A-Za-z_$][\w$]*)\.performance\.mark\(`transcript_dispatched`\),([A-Za-z_$][\w$]*)\.action===`send`\?([A-Za-z_$][\w$]*)\.onTranscriptSend\(\1\):\6\.onTranscriptInsert\(\1\)\)/gu; const uniqueMatch = (pattern) => { @@ -319,7 +319,7 @@ function applyDictationEndpointPatch(source) { patched = patched.replace( recorderStartMatch[0], () => - `${recorderVar}.onstop=()=>{${recorderStartMatch[2]}()},${recorderVar}.chatgptLinuxConversationCleanup=globalThis.chatgptLinuxConversationEndpoint?.({stream:${streamVar},stop:()=>{${actionRef}.current=\`send\`;${recorderVar}.state!==\`inactive\`&&${recorderVar}.stop()},isActive:()=>${recorderRefVar}.current===${recorderVar}&&${recorderVar}.state!==\`inactive\`}),${recorderStartMatch[3]}==null?${recorderVar}.start():${recorderVar}.start(${recorderStartMatch[4]}),${recorderStartMatch[5]}.performance.mark(\`recording_started\`),${recorderStartMatch[6]}(!0)`, + `${recorderVar}.onstop=()=>{${recorderStartMatch[2]}()},${recorderVar}.chatgptLinuxConversationCleanup=globalThis.chatgptLinuxConversationEndpoint?.({stream:${streamVar},stop:()=>{${actionRef}.current=\`send\`;${recorderVar}.state!==\`inactive\`&&${recorderVar}.stop()},isActive:()=>${recorderRefVar}.current===${recorderVar}&&${recorderVar}.state!==\`inactive\`}),${recorderStartMatch[3]}==null?${recorderVar}.start():${recorderVar}.start(${recorderStartMatch[4]}),${recorderStartMatch[5]}(!0),${recorderStartMatch[6]}.performance.mark(\`recording_started\`),${recorderStartMatch[7]}(!0)`, ); return patched.replace( transcriptMatch[0], diff --git a/port-integrations/conversation-mode/test.js b/port-integrations/conversation-mode/test.js index cb712e5c3..7f5ac366e 100644 --- a/port-integrations/conversation-mode/test.js +++ b/port-integrations/conversation-mode/test.js @@ -176,7 +176,7 @@ const currentDictationAsset = currentAppInitialAsset; const currentAssistantAsset = "local-conversation-turn-current.js"; const dictationSource = - "function Opt(){let C={current:null},z={current:null},O={current:[]},k={current:null};let pe=async e=>{let{analytics:t,audio:r,handlers:i,recordingPersistence:a}=e;let l=`hello`;l.length>0&&(a==null?_m.getInstance().dispatchMessage(`global-dictation-record-history-item`,{text:l}):a.setTranscript(l),t.performance.mark(`transcript_dispatched`),e.action===`send`?i.onTranscriptSend(l):i.onTranscriptInsert(l))},ge=async()=>{let e=k.current??`insert`,t={performance:{mark(){}}},n={onTranscriptInsert(){},onTranscriptSend(){}};k.current=null;let i=C.current,a=z.current;z.current=null;let o=O.current;if(O.current=[],i&&(i.ondataavailable=null,i.onstop=null),C.current=null,q(),a?.finalize(e===`abort`||o.length===0,100),e===`abort`||o.length===0)return;await pe({action:e,analytics:t,audio:o,handlers:n,recordingPersistence:a})};return{startDictation:async()=>{let a=wpt(null,null),t=await a.stream,n={performance:{mark(){}}};let c=new MediaRecorder(t);C.current=c;let l=Xit({createdAtMs:1});if(z.current=l,O.current=[],c.ondataavailable=e=>{e.data.size>0&&(O.current.push(e.data),l?.appendChunk(e.data,c.mimeType||e.data.type||`audio/webm`))},c.onstop=()=>{ge()},l==null?c.start():c.start(Zit),n.performance.mark(`recording_started`),_(!0),k.current!=null){c.stop();return}}}}function wpt(e,t){let n=!1,r=null,i=()=>{n=!0,r?.getTracks().forEach(e=>{e.stop()}),r=null};return{dispose:i,stream:(e==null?Udt({channelCount:1},t):Gdt({channelCount:1},e,t)).then(e=>(r=e,n&&i(),e))}}"; + "function Opt(){let C={current:null},z={current:null},O={current:[]},k={current:null};let pe=async e=>{let{analytics:t,audio:r,handlers:i,recordingPersistence:a}=e;let l=`hello`;l.length>0&&(a==null?_m.getInstance().dispatchMessage(`global-dictation-record-history-item`,{text:l}):a.setTranscript(l),t.performance.mark(`transcript_dispatched`),e.action===`send`?i.onTranscriptSend(l):i.onTranscriptInsert(l))},ge=async()=>{let e=k.current??`insert`,t={performance:{mark(){}}},n={onTranscriptInsert(){},onTranscriptSend(){}};k.current=null;let i=C.current,a=z.current;z.current=null;let o=O.current;if(O.current=[],i&&(i.ondataavailable=null,i.onstop=null),C.current=null,q(),a?.finalize(e===`abort`||o.length===0,100),e===`abort`||o.length===0)return;await pe({action:e,analytics:t,audio:o,handlers:n,recordingPersistence:a})};return{startDictation:async()=>{let a=wpt(null,null),t=await a.stream,n={performance:{mark(){}}};let c=new MediaRecorder(t);C.current=c;let l=Xit({createdAtMs:1});if(z.current=l,O.current=[],c.ondataavailable=e=>{e.data.size>0&&(O.current.push(e.data),l?.appendChunk(e.data,c.mimeType||e.data.type||`audio/webm`))},c.onstop=()=>{ge()},l==null?c.start():c.start(Zit),ue(!0),n.performance.mark(`recording_started`),_(!0),k.current!=null){c.stop();return}}}}function wpt(e,t){let n=!1,r=null,i=()=>{n=!0,r?.getTracks().forEach(e=>{e.stop()}),r=null};return{dispose:i,stream:(e==null?Udt({channelCount:1},t):Gdt({channelCount:1},e,t)).then(e=>(r=e,n&&i(),e))}}"; const currentComposerControlSource = "function Vka(e){let{isResponseInProgress:x,onStop:T,submitBlockReason:E,voiceControls:A}=e,j=Nn(Bk),M=RZ(),N=Rk(j),P=LEa(j.value,t),{canRetryDictation:B,dictationShortcutLabel:V,isDictating:U,isDictationButtonVisible:W,isDictationSupported:G,isTranscribing:ee,isVoiceFooterVisible:te,recordingDurationMs:ne,retryDictation:K,startDictation:re,stopDictation:ie,realtimeSession:ae,waveformCanvasRef:oe}=A;let je=(0,x7.jsx)(_ka,{conversationId:N,hostId:g,cwdOverride:_}),ke=(0,x7.jsx)(Twe,{isTranscribing:ee,recordingDurationMs:ne,waveformCanvasRef:oe,stopDictation:ie});let Ae=(0,x7.jsx)(Ewe,{idleIcon:U,isVisible:W,disabled:!G,isTranscribing:ee,canRetryDictation:B,shortcutLabel:V,retryDictation:K,startDictation:re,stopDictation:ie});return Ae}"; diff --git a/port-integrations/project-group-last-updated-sort/patch.js b/port-integrations/project-group-last-updated-sort/patch.js index a995ea011..e513b3cb9 100644 --- a/port-integrations/project-group-last-updated-sort/patch.js +++ b/port-integrations/project-group-last-updated-sort/patch.js @@ -1,14 +1,14 @@ "use strict"; const currentGroupSorter = - "function ROn({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return bEn(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}"; + "function von({groups:e,projectOrder:t}){return sin(e,t)}"; const patchedGroupSorter = - "function ROn({groups:e,items:t,projectOrder:n,sortMode:chatgptLinuxProjectSortMode}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return((chatgptLinuxRecencySortedGroups)=>chatgptLinuxProjectSortMode===`updated_at`?chatgptLinuxRecencySortedGroups:bEn(chatgptLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e))}"; + "function von({groups:e,projectOrder:t,items:n,sortMode:r}){if(r!==`updated_at`)return sin(e,t);let i=new Map;for(let e of n??[]){let t=e.task?.key;t!=null&&i.set(t,Math.max(i.get(t)??0,e.recencyAt??0))}return e.map((e,t)=>({group:e,index:t,recencyAt:(e.threadKeys??[]).reduce((e,t)=>Math.max(e,i.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e)}"; const currentGroupSorterCall = - "M=ROn({groups:k,items:f,projectOrder:cm(t,ru.PROJECT_ORDER)})"; + "M=von({groups:k,projectOrder:im(t,tu.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "M=ROn({groups:k,items:f,projectOrder:cm(t,ru.PROJECT_ORDER),sortMode:j})"; + "M=von({groups:k,projectOrder:im(t,tu.PROJECT_ORDER),items:f,sortMode:j})"; function countOccurrences(source, needle) { return source.split(needle).length - 1; diff --git a/port-integrations/project-group-last-updated-sort/test.js b/port-integrations/project-group-last-updated-sort/test.js index c17908875..34efcaca8 100644 --- a/port-integrations/project-group-last-updated-sort/test.js +++ b/port-integrations/project-group-last-updated-sort/test.js @@ -19,12 +19,12 @@ const { const currentProjectSource = [ "function vEn(e,t){let n=new Set(e.map(e=>e.projectId)),r=(t??[]).filter(e=>n.has(e)),i=new Set(r);return[...e.map(e=>e.projectId).filter(e=>!i.has(e)),...r]}", - "function bEn(e,t){let n=vEn(e,t),r=new Map(n.map((e,t)=>[e,t]));return[...e].sort((e,t)=>(r.get(e.projectId)??2**53-1)-(r.get(t.projectId)??2**53-1))}", - "function ROn({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return bEn(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}", + "function sin(e,t){let n=vEn(e,t),r=new Map(n.map((e,t)=>[e,t]));return[...e].sort((e,t)=>(r.get(e.projectId)??2**53-1)-(r.get(t.projectId)??2**53-1))}", + "function von({groups:e,projectOrder:t}){return sin(e,t)}", "const prioritySortId=`sidebarElectron.sortMenu.priority`;", "const updatedSortId=`sidebarElectron.sortMenu.updated`;", "const manualSortId=`sidebarElectron.sortMenu.manual`;", - "const {projectSortMode:j}=t(qw);M=ROn({groups:k,items:f,projectOrder:cm(t,ru.PROJECT_ORDER)});", + "const {projectSortMode:j}=t(Nb);M=von({groups:k,projectOrder:im(t,tu.PROJECT_ORDER)});", ].join(""); function captureWarns(fn) { @@ -80,7 +80,7 @@ function withFeatureConfig(enabled, fn) { function evaluateGroupSorter(source) { const context = {}; const sorterSource = source.slice(0, source.indexOf("const prioritySortId")); - vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=ROn`, context); + vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=von`, context); return context.sortProjectGroups; } @@ -160,19 +160,65 @@ test("non-updated modes preserve the upstream saved project order", () => { } }); +test("sorting tolerates missing current item and thread metadata", () => { + const patched = applyPatchTwice(currentProjectSource); + const sortProjectGroups = evaluateGroupSorter(patched); + const groups = [ + { projectId: "newer" }, + { projectId: "older", threadKeys: ["older-task"] }, + ]; + const projectOrder = ["older", "newer"]; + + assert.deepEqual( + Array.from( + sortProjectGroups({ groups, projectOrder, sortMode: "manual" }), + (group) => group.projectId, + ), + ["older", "newer"], + ); + assert.deepEqual( + Array.from( + sortProjectGroups({ + groups, + items: [{ task: null, recencyAt: 2 }], + projectOrder, + sortMode: "updated_at", + }), + (group) => group.projectId, + ), + ["newer", "older"], + ); + assert.deepEqual( + Array.from( + sortProjectGroups({ + groups, + items: [ + { task: null, recencyAt: 9 }, + { task: { key: "older-task" }, recencyAt: 5 }, + { task: { key: "older-task" }, recencyAt: 3 }, + ], + projectOrder, + sortMode: "updated_at", + }), + (group) => group.projectId, + ), + ["older", "newer"], + ); +}); + test("patch passes the selected project sort mode into the group sorter", () => { const patched = applyPatchTwice(currentProjectSource); assert.ok( patched.includes( - "projectOrder:cm(t,ru.PROJECT_ORDER),sortMode:j", + "projectOrder:im(t,tu.PROJECT_ORDER),items:f,sortMode:j", ), ); }); test("drift leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "function ROn({groups:e,items:t,projectOrder:n})", - "function ROn({groups:e,items:t,projectOrder:n,unknown:o})", + "function von({groups:e,projectOrder:t})", + "function von({groups:e,projectOrder:t,unknown:o})", ); const { value, warnings } = captureWarns(() => applyProjectGroupLastUpdatedSortPatch(source), @@ -185,7 +231,7 @@ test("drift leaves the asset byte-identical", () => { test("missing current call site leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "projectOrder:cm(t,ru.PROJECT_ORDER)", + "projectOrder:im(t,tu.PROJECT_ORDER)", "projectOrder:unknownProjectOrder", ); const { value, warnings } = captureWarns(() => diff --git a/port-integrations/read-aloud-mcp/patches.js b/port-integrations/read-aloud-mcp/patches.js index 0ac91cc50..05d407bd0 100644 --- a/port-integrations/read-aloud-mcp/patches.js +++ b/port-integrations/read-aloud-mcp/patches.js @@ -1,5 +1,9 @@ "use strict"; +const { + findExecutableJavaScriptSubstring, +} = require("../../scripts/patches/lib/minified-js.js"); + const READ_ALOUD_PLUGIN_NAME = "read-aloud"; function escapeRegExp(value) { @@ -8,29 +12,20 @@ function escapeRegExp(value) { function hasReadAloudPluginGate(source) { const pluginGateArray = findBundledPluginGateArray(source); - const target = pluginGateArray?.text ?? source; - const nameExpression = pluginNameExpressionRegex(source, READ_ALOUD_PLUGIN_NAME); - return new RegExp( - String.raw`\{(?:[^{}]*,)?name:${nameExpression},(?:isEnabled|isAvailable):`, - ).test(target); -} - -function pluginNameExpressionRegex(source, pluginName) { - const escapedPluginName = escapeRegExp(pluginName); - const boundName = sourceBoundName(source, pluginName); - return boundName == null - ? String.raw`(?:\`${escapedPluginName}\`|"${escapedPluginName}"|'${escapedPluginName}')` - : String.raw`(?:${escapeRegExp(boundName)}|\`${escapedPluginName}\`|"${escapedPluginName}"|'${escapedPluginName}')`; -} - -function sourceBoundName(source, pluginName) { - return source.match( - new RegExp(String.raw`([A-Za-z_$][\w$]*)=(?:\`${escapeRegExp(pluginName)}\`|"${escapeRegExp(pluginName)}"|'${escapeRegExp(pluginName)}')`), - )?.[1] ?? null; + if (pluginGateArray == null) { + return false; + } + const descriptor = buildReadAloudDescriptor(); + const matchIndex = findExecutableJavaScriptSubstring( + source, + descriptor, + pluginGateArray.start, + ); + return matchIndex >= pluginGateArray.start && matchIndex + descriptor.length <= pluginGateArray.end; } -function buildReadAloudDescriptor(availabilityProp) { - return `{installWhenMissing:!0,name:\`${READ_ALOUD_PLUGIN_NAME}\`,${availabilityProp}:({platform:e})=>e===\`linux\`}`; +function buildReadAloudDescriptor() { + return `{installWhenMissing:!0,name:\`${READ_ALOUD_PLUGIN_NAME}\`,isAvailable:({platform:e})=>e===\`linux\`}`; } function findMatchingBracket(source, openIndex) { @@ -66,46 +61,80 @@ function findMatchingBracket(source, openIndex) { return -1; } +function executableRegexMatches(source, pattern, text = source, start = 0) { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return [...text.matchAll(new RegExp(pattern.source, flags))].filter((match) => { + if (match.index == null) { + return false; + } + const absoluteIndex = start + match.index; + return findExecutableJavaScriptSubstring(source, match[0], absoluteIndex) === absoluteIndex; + }); +} + function findBundledPluginGateArray(source) { - let markerIndex = source.indexOf(".computerUse"); - while (markerIndex !== -1) { - const openIndex = source.lastIndexOf("[", markerIndex); - if (openIndex === -1) { - return null; + const spreadComputerUseRegex = /\.\.\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\.computerUse\b/g; + const candidates = new Map(); + + for (const marker of executableRegexMatches(source, spreadComputerUseRegex)) { + const registryExpression = marker[1]; + let openIndex = source.lastIndexOf("[", marker.index); + let closeIndex = -1; + while (openIndex !== -1) { + closeIndex = findMatchingBracket(source, openIndex); + if (closeIndex !== -1 && marker.index < closeIndex) { + break; + } + openIndex = source.lastIndexOf("[", openIndex - 1); + closeIndex = -1; } - const closeIndex = findMatchingBracket(source, openIndex); - if (closeIndex !== -1 && markerIndex < closeIndex) { + if (openIndex !== -1 && closeIndex !== -1) { const text = source.slice(openIndex + 1, closeIndex); + const escapedRegistry = escapeRegExp(registryExpression); + const latexDescriptorRegex = new RegExp( + String.raw`\{\.\.\.${escapedRegistry}\.latex,isAvailable:\(\)=>!0\}`, + ); + const computerUseDescriptorRegex = new RegExp( + String.raw`\{\.\.\.${escapedRegistry}\.computerUse,[^{}]*isAvailable:\(\{features:[A-Za-z_$][\w$]*,platform:[A-Za-z_$][\w$]*\}\)=>[A-Za-z_$][\w$]*===\`(darwin|win32)\`&&[A-Za-z_$][\w$]*\.computerUse(?:,[^{}]*)?\}`, + "g", + ); + const computerUseMatches = executableRegexMatches( + source, + computerUseDescriptorRegex, + text, + openIndex + 1, + ); + const computerUsePlatforms = new Set( + computerUseMatches.map((match) => match[1]), + ); + const latexMatches = executableRegexMatches( + source, + latexDescriptorRegex, + text, + openIndex + 1, + ); if ( - text.includes("installWhenMissing") && - text.includes("name:") && - /(?:isEnabled|isAvailable):/.test(text) + latexMatches.length === 1 && + computerUseMatches.length === 2 && + computerUsePlatforms.size === 2 && + computerUsePlatforms.has("darwin") && + computerUsePlatforms.has("win32") ) { - return { + candidates.set(`${openIndex}:${closeIndex}`, { start: openIndex + 1, end: closeIndex, text, - }; + registryExpression, + insertionOffset: latexMatches[0].index, + }); } } - markerIndex = source.indexOf(".computerUse", markerIndex + ".computerUse".length); } - return null; -} - -function findAlwaysOnBundledDescriptor(pluginGateArray) { - const pluginNameExpression = - "(?:[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)?|`[^`]+`|\"[^\"]+\"|'[^']+')"; - const alwaysOnDescriptorRegex = new RegExp( - String.raw`\{name:(${pluginNameExpression}),(isEnabled|isAvailable):\(\)=>!0\}`, - "g", - ); - let lastMatch = null; - for (const match of pluginGateArray.text.matchAll(alwaysOnDescriptorRegex)) { - lastMatch = match; + if (candidates.size > 1) { + throw new Error("Required Linux Read Aloud plugin gate patch failed: bundled plugin descriptor array is ambiguous"); } - return lastMatch; + return candidates.values().next().value ?? null; } function applyLinuxReadAloudPluginGatePatch(currentSource) { @@ -115,20 +144,14 @@ function applyLinuxReadAloudPluginGatePatch(currentSource) { const pluginGateArray = findBundledPluginGateArray(currentSource); if (pluginGateArray == null) { - if (currentSource.includes(".computerUse")) { + if (findExecutableJavaScriptSubstring(currentSource, ".computerUse") >= 0) { throw new Error("Required Linux Read Aloud plugin gate patch failed: could not find bundled plugin descriptor array"); } return currentSource; } - const match = findAlwaysOnBundledDescriptor(pluginGateArray); - if (match == null) { - throw new Error("Required Linux Read Aloud plugin gate patch failed: could not find bundled plugin descriptor insertion point"); - } - - const [_descriptor, _pluginName, availabilityProp] = match; - const insertionIndex = pluginGateArray.start + match.index; - return `${currentSource.slice(0, insertionIndex)}${buildReadAloudDescriptor(availabilityProp)},${currentSource.slice(insertionIndex)}`; + const insertionIndex = pluginGateArray.start + pluginGateArray.insertionOffset; + return `${currentSource.slice(0, insertionIndex)}${buildReadAloudDescriptor()},${currentSource.slice(insertionIndex)}`; } const descriptors = [ diff --git a/port-integrations/read-aloud-mcp/test.js b/port-integrations/read-aloud-mcp/test.js index 6113d79df..eec956569 100644 --- a/port-integrations/read-aloud-mcp/test.js +++ b/port-integrations/read-aloud-mcp/test.js @@ -59,6 +59,12 @@ test("read-aloud-mcp stages by default", () => { path.join(__dirname, "patches.js"), path.join(integrationsRoot, "read-aloud-mcp", "patches.js"), ); + const patchHelperDir = path.join(tempDir, "scripts/patches/lib"); + fs.mkdirSync(patchHelperDir, { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, "scripts/patches/lib/minified-js.js"), + path.join(patchHelperDir, "minified-js.js"), + ); fs.writeFileSync(path.join(integrationsRoot, "integrations.example.json"), '{"enabled":[]}\n'); assert.equal(enabledPortIntegrationStageHooks({ integrationsRoot }).length, 1); @@ -72,59 +78,112 @@ test("read-aloud-mcp stages by default", () => { } }); -test("read-aloud-mcp plugin gate adds a default Linux bundled plugin", () => { +test("read-aloud-mcp plugin gate adds a default Linux bundled plugin to the current descriptor array", () => { const source = [ - "var lt=`browser-use`,ut=`chrome`,dt=`chrome-internal`,ft=`computer-use`,pt=`latex-tectonic`;", - "var Kr=[{forceReload:!0,installWhenMissing:!0,name:lt,isAvailable:({features:e})=>e.inAppBrowserUseAllowed,migrate:rr},{forceReload:!0,name:dt,isAvailable:({buildFlavor:e,features:t})=>Qn(e)&&t.externalBrowserUseAllowed},{forceReload:!0,name:ut,isAvailable:({buildFlavor:e,features:t})=>t.externalBrowserUseAllowed&&$n(e)},{name:ft,isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:vr},{installWhenMissing:!0,name:ft,isAvailable:({buildFlavor:e,features:n,platform:r})=>t.T.isInternal(e)&&r===`win32`&&n.computerUse},{name:pt,isAvailable:()=>!0}];", + "var gc=[{...n.Ds.codexAppTools,isAvailable:()=>!0},{...n.Ds.sites,isAvailable:({features:e})=>e.sites},{...n.Ds.browser,isAvailable:({features:e})=>e.inAppBrowserUseAllowed},{...n.Ds.chromeDev,isAvailable:({features:e})=>e.chromeDev},{...n.Ds.chromeInternal,isAvailable:({features:e})=>e.chromeInternal},{...n.Ds.chrome,isAvailable:({features:e})=>e.chrome},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.messages,isAvailable:({features:e})=>e.messages},{...n.Ds.latex,isAvailable:()=>!0},{...n.Ds.visualize,isAvailable:()=>!0}];", ].join(""); const patched = applyPatchTwice(applyLinuxReadAloudPluginGatePatch, source); assert.match( patched, - /\{installWhenMissing:!0,name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`\},\{name:pt,isAvailable:\(\)=>!0\}/, + /\{installWhenMissing:!0,name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`\},\{\.\.\.n\.Ds\.latex,isAvailable:\(\)=>!0\}/, ); }); -test("read-aloud-mcp plugin gate supports older isEnabled bundle shapes", () => { +test("read-aloud-mcp plugin gate ignores a read-aloud name decoy in the current descriptor array", () => { const source = [ - "var Qt=`openai-bundled`,$t=`browser-use`,en=`chrome-internal`,tn=`computer-use`,nn=`latex-tectonic`;", - "var $n=[{forceReload:!0,installWhenMissing:!0,name:$t,isEnabled:({features:e})=>e.browserAgentAvailable,migrate:cn},{name:en,isEnabled:({buildFlavor:e})=>rn(e)},{name:tn,isEnabled:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:wn},{name:nn,isEnabled:()=>!0}];", + "var ra=`read-aloud`;", + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{name:ra,isAvailable:()=>!1},{...n.Ds.latex,isAvailable:()=>!0}];", ].join(""); const patched = applyPatchTwice(applyLinuxReadAloudPluginGatePatch, source); assert.match( patched, - /\{installWhenMissing:!0,name:`read-aloud`,isEnabled:\(\{platform:e\}\)=>e===`linux`\},\{name:nn,isEnabled:\(\)=>!0\}/, + /\{installWhenMissing:!0,name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`\},\{\.\.\.n\.Ds\.latex,isAvailable:\(\)=>!0\}/, + ); +}); + +test("read-aloud-mcp plugin gate resolves the descriptor array past an earlier inner bracket", () => { + const source = + "var gc=[{name:`earlier`,values:[1,2]},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.latex,isAvailable:()=>!0}];"; + + const patched = applyPatchTwice(applyLinuxReadAloudPluginGatePatch, source); + assert.match( + patched, + /values:\[1,2\].*\{installWhenMissing:!0,name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`\},\{\.\.\.n\.Ds\.latex/u, + ); +}); + +test("read-aloud-mcp plugin gate ignores quoted and commented current descriptor arrays", () => { + const descriptorArray = + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.latex,isAvailable:()=>!0}];"; + const quoted = `const decoy=${JSON.stringify(descriptorArray)};`; + const commented = `/*${descriptorArray}*/`; + + assert.equal(applyLinuxReadAloudPluginGatePatch(quoted), quoted); + assert.equal(applyLinuxReadAloudPluginGatePatch(commented), commented); +}); + +test("read-aloud-mcp plugin gate rejects quoted and commented latex descriptor decoys", () => { + const prefix = + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},"; + const quoted = `${prefix}\"{...n.Ds.latex,isAvailable:()=>!0}\"];`; + const commented = `${prefix}/*{...n.Ds.latex,isAvailable:()=>!0}*/];`; + + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(quoted), + /could not find bundled plugin descriptor array/, + ); + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(commented), + /could not find bundled plugin descriptor array/, ); }); -test("read-aloud-mcp plugin gate ignores unrelated read-aloud strings", () => { +test("read-aloud-mcp plugin gate fails closed without both current platform descriptors", () => { const source = [ - "function chatgptLinuxReadAloudSettings(){return `read-aloud-settings`}", - "var lt=`browser-use`,ut=`chrome`,dt=`chrome-internal`,ft=`computer-use`,pt=`latex-tectonic`;", - "var Kr=[{forceReload:!0,installWhenMissing:!0,name:lt,isAvailable:({features:e})=>e.inAppBrowserUseAllowed,migrate:rr},{name:ft,isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:vr},{name:pt,isAvailable:()=>!0}];", + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse},{...n.Ds.latex,isAvailable:()=>!0}];", ].join(""); - const patched = applyLinuxReadAloudPluginGatePatch(source); + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(source), + /could not find bundled plugin descriptor array/, + ); +}); - assert.match( - patched, - /name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`/, +test("read-aloud-mcp plugin gate rejects the obsolete inline isEnabled shape", () => { + const source = [ + "var browser=`browser-use`,computer=`computer-use`,latex=`latex-tectonic`;", + "var plugins=[{installWhenMissing:!0,name:browser,isEnabled:({features:e})=>e.browserAgentAvailable},{name:computer,isEnabled:({features:e,platform:t})=>t===`darwin`&&e.computerUse},{name:latex,isEnabled:()=>!0}];", + ].join(""); + + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(source), + /could not find bundled plugin descriptor array/, ); }); -test("read-aloud-mcp plugin gate handles current imported namespace constants", () => { +test("read-aloud-mcp plugin gate rejects ambiguous current descriptor arrays", () => { const source = [ - "var ti=[{autoInstallOptOutKey:e.yn(e._n),installWhenMissing:!0,name:e._n,isAvailable:({buildFlavor:e})=>ei(e)},{autoInstallOptOutKey:e.yn(e.pn),forceReload:!0,installWhenMissing:!0,name:e.pn,isAvailable:({features:e})=>e.inAppBrowserUseAllowed,migrate:dr},{forceReload:!0,name:ft,isAvailable:({buildFlavor:e,env:t,features:n})=>ar(e,t)&&n.externalBrowserUseAllowed},{forceReload:!0,name:e.mn,isAvailable:({buildFlavor:e,env:t,features:n})=>or(e,t)&&n.externalBrowserUseAllowed},{forceReload:!0,installWhenMissing:!0,name:dt,isAvailable:({buildFlavor:e,features:t})=>t.externalBrowserUseAllowed&&sr(e)},{installWhenMissing:!0,name:e.hn,isAvailable:({features:e,platform:t})=>(t===`darwin`||t===`linux`)&&e.computerUse,migrate:Er},{forceReload:!0,installWhenMissing:!0,name:e.hn,isAvailable:({buildFlavor:e,features:n,platform:r})=>t.D.isInternal(e)&&r===`win32`&&n.computerUse},{name:e.gn,isAvailable:()=>!0}];", + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.latex,isAvailable:()=>!0}];", + "var hc=[{...r.Ks.computerUse,autoInstallOptOutKey:r.As(r.Ks.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Js},{...r.Ks.computerUse,autoInstallOptOutKey:r.As(r.Ks.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...r.Ks.latex,isAvailable:()=>!0}];", ].join(""); - const patched = applyPatchTwice(applyLinuxReadAloudPluginGatePatch, source); + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(source), + /bundled plugin descriptor array is ambiguous/, + ); +}); - assert.match( - patched, - /\{installWhenMissing:!0,name:`read-aloud`,isAvailable:\(\{platform:e\}\)=>e===`linux`\},\{name:e\.gn,isAvailable:\(\)=>!0\}/, +test("read-aloud-mcp plugin gate rejects an ambiguous current descriptor within one array", () => { + const source = + "var gc=[{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:Hs},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.computerUse,autoInstallOptOutKey:n.As(n.Ds.computerUse.name),isAvailable:({features:e,platform:t})=>t===`win32`&&e.computerUse},{...n.Ds.latex,isAvailable:()=>!0}];"; + + assert.throws( + () => applyLinuxReadAloudPluginGatePatch(source), + /could not find bundled plugin descriptor array/, ); }); diff --git a/port-integrations/remote-mobile-control/patch.js b/port-integrations/remote-mobile-control/patch.js index 2555b9512..144c2db7e 100644 --- a/port-integrations/remote-mobile-control/patch.js +++ b/port-integrations/remote-mobile-control/patch.js @@ -11,9 +11,9 @@ function requireName(source, moduleName) { const DEVICE_KEY_CLIENT_MARKER = "chatgptLinuxRemoteControlDeviceKeyClient"; const DEVICE_KEY_GUARD = - "if(process.platform!==`darwin`)throw Error(`Remote control device keys are only available on macOS`);"; + "if(process.platform!==`darwin`&&process.platform!==`win32`)throw Error(`Remote control device keys are only available on macOS and Windows`);"; const DEVICE_KEY_GUARD_REPLACEMENT = - "if(process.platform===`linux`)return chatgptLinuxRemoteControlDeviceKeyClient();if(process.platform!==`darwin`)throw Error(`Remote control device keys are only available on macOS`);"; + "if(process.platform===`linux`)return chatgptLinuxRemoteControlDeviceKeyClient();if(process.platform!==`darwin`&&process.platform!==`win32`)throw Error(`Remote control device keys are only available on macOS and Windows`);"; const DEVICE_KEY_REQUIRE_NEEDLE = /(?:var|let|const)\s+[A-Za-z_$][\w$]*=\(0,[A-Za-z_$][\w$]*\.createRequire\)\(__filename\),[A-Za-z_$][\w$]*=`remote-control-device-key\.node`/u; const REMOTE_CONTROL_SETTINGS_VISIBILITY_NEEDLE = @@ -28,7 +28,6 @@ const REMOTE_CONTROL_LOAD_GATE_MARKER = "chatgptLinuxRemoteControlLoadGateEnable const REMOTE_CONTROL_FEATURE_SYNC_MARKER = "chatgptLinuxRemoteControlIntegrationSyncEnabled"; const REMOTE_CONTROL_LOAD_GATE_NEEDLE = /function ([A-Za-z_$][\w$]*)\(\)\{return ([A-Za-z_$][\w$]*)\(`1042620455`\)\}/u; -const REMOTE_MOBILE_THREAD_RUNTIME_MARKER = "chatgptLinuxRemoteMobileThreadRuntimeStatus"; const REMOTE_MOBILE_UNKNOWN_TURN_MARKER = "chatgptLinuxRemoteMobileHydrateUnknownTurn"; const REMOTE_MOBILE_NOTIFICATION_QUEUE_MARKER = "chatgptLinuxRemoteMobileNotificationQueue"; const REMOTE_MOBILE_IN_FLIGHT_HYDRATION_MARKER = "chatgptLinuxRemoteMobileHydrationInFlight"; @@ -637,106 +636,102 @@ function browserClientHasNativeChromeBackendPreferenceRouting(source) { ); } -function buildLateUnknownConversationHydrationReplacement( - eventName, +function buildCurrentUnknownConversationHydrationReplacement({ + contextVar, conversationIdVar, - loggerVar, - unknownConversationPrelude = "", -) { - const pendingMapVar = "chatgptLinuxRemoteMobilePendingMap"; - const queueVar = "chatgptLinuxRemoteMobileQueue"; - const inFlightVar = "chatgptLinuxRemoteMobileInFlight"; - const readVar = "chatgptLinuxRemoteMobileRead"; + eventName, + loggerExpression, + managerVar, + notificationVar, + prelude = "", +}) { return ( - `if(!this.conversations.get(${conversationIdVar})){/*${REMOTE_MOBILE_LATE_EVENT_HYDRATION_MARKER}*/${unknownConversationPrelude}${unknownConversationPrelude.length > 0 ? ";" : ""}` + - `let ${pendingMapVar}=this.chatgptLinuxRemoteMobilePendingNotifications??=new Map,${queueVar}=${pendingMapVar}.get(${conversationIdVar});` + - `${queueVar}||(${queueVar}=[],${pendingMapVar}.set(${conversationIdVar},${queueVar})),${queueVar}.push(n);` + - `let ${inFlightVar}=this.chatgptLinuxRemoteMobileInFlightHydrations??=new Set;` + - `if(${inFlightVar}.has(${conversationIdVar})){${loggerVar}.warning(\`Queueing ${eventName} for hydrating conversation\`,{safe:{queuedNotificationCount:${queueVar}.length},sensitive:{conversationId:${conversationIdVar}}});break}` + - `${loggerVar}.warning(\`Hydrating conversation for ${eventName}\`,{safe:{queuedNotificationCount:${queueVar}.length},sensitive:{conversationId:${conversationIdVar}}});` + - `let ${readVar}=(s=0)=>this.readThread(${conversationIdVar},{includeTurns:!0}).then(e=>{let t=e?.thread??e,c=this.chatgptLinuxRemoteMobilePendingNotifications?.get(${conversationIdVar})??[],chatgptLinuxRemoteMobileTurns=Array.isArray(e?.turns)?e.turns:Array.isArray(t?.turns)?t.turns:null;` + - `if(!t||!Array.isArray(chatgptLinuxRemoteMobileTurns)||chatgptLinuxRemoteMobileTurns.length===0){if(s<12){${loggerVar}.warning(\`Retrying hydration for missing conversation\`,{safe:{queuedNotificationCount:c.length,attempt:s+1},sensitive:{conversationId:${conversationIdVar}}}),setTimeout(()=>${readVar}(s+1),250);return}` + - `this.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar}),${loggerVar}.warning(\`Skipping hydration for missing conversation\`,{safe:{queuedNotificationCount:c.length},sensitive:{conversationId:${conversationIdVar}}});return}` + - `this.upsertConversationFromThread(t),this.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar});for(let e of c)this.onNotification(e.method,e.params)})` + - `.catch(e=>{if(s<12){${loggerVar}.warning(\`Retrying hydration for ${eventName}\`,{safe:{attempt:s+1},sensitive:{conversationId:${conversationIdVar},error:e}}),setTimeout(()=>${readVar}(s+1),250);return}` + - `this.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar}),${loggerVar}.error(\`Failed to hydrate conversation for ${eventName}\`,{safe:{},sensitive:{conversationId:${conversationIdVar},error:e}})});` + - `${inFlightVar}.add(${conversationIdVar}),${readVar}();break}` + `if(!${contextVar}.threadStore.conversations.get(${conversationIdVar})){/*${REMOTE_MOBILE_LATE_EVENT_HYDRATION_MARKER}*/${prelude}${prelude.length > 0 ? ";" : ""}` + + `let chatgptLinuxRemoteMobilePendingMap=${managerVar}.chatgptLinuxRemoteMobilePendingNotifications??=new Map,chatgptLinuxRemoteMobileQueue=chatgptLinuxRemoteMobilePendingMap.get(${conversationIdVar});` + + `chatgptLinuxRemoteMobileQueue||(chatgptLinuxRemoteMobileQueue=[],chatgptLinuxRemoteMobilePendingMap.set(${conversationIdVar},chatgptLinuxRemoteMobileQueue));` + + `let chatgptLinuxRemoteMobileInFlight=${managerVar}.chatgptLinuxRemoteMobileInFlightHydrations??=new Set;` + + `if(chatgptLinuxRemoteMobileQueue.length>=512){${loggerExpression}.warning(\`Dropping ${eventName} while hydrating conversation\`,{safe:{queuedNotificationCount:chatgptLinuxRemoteMobileQueue.length,droppedNotification:!0},sensitive:{conversationId:${conversationIdVar}}});if(chatgptLinuxRemoteMobileInFlight.has(${conversationIdVar}))break}else chatgptLinuxRemoteMobileQueue.push(${notificationVar});` + + `if(chatgptLinuxRemoteMobileInFlight.has(${conversationIdVar})){${loggerExpression}.warning(\`Queueing ${eventName} for hydrating conversation\`,{safe:{queuedNotificationCount:chatgptLinuxRemoteMobileQueue.length},sensitive:{conversationId:${conversationIdVar}}});break}` + + `${loggerExpression}.warning(\`Hydrating conversation for ${eventName}\`,{safe:{queuedNotificationCount:chatgptLinuxRemoteMobileQueue.length},sensitive:{conversationId:${conversationIdVar}}});` + + `let chatgptLinuxRemoteMobileRead=(chatgptLinuxRemoteMobileAttempt=0)=>${managerVar}.readThread(${conversationIdVar},{includeTurns:!0}).then(chatgptLinuxRemoteMobileResult=>{let chatgptLinuxRemoteMobileThread=chatgptLinuxRemoteMobileResult?.thread??chatgptLinuxRemoteMobileResult,chatgptLinuxRemoteMobileQueued=${managerVar}.chatgptLinuxRemoteMobilePendingNotifications?.get(${conversationIdVar})??[],chatgptLinuxRemoteMobileTurns=Array.isArray(chatgptLinuxRemoteMobileResult?.turns)?chatgptLinuxRemoteMobileResult.turns:Array.isArray(chatgptLinuxRemoteMobileThread?.turns)?chatgptLinuxRemoteMobileThread.turns:null;` + + `if(!chatgptLinuxRemoteMobileThread||!Array.isArray(chatgptLinuxRemoteMobileTurns)||chatgptLinuxRemoteMobileTurns.length===0){if(chatgptLinuxRemoteMobileAttempt<12){${loggerExpression}.warning(\`Retrying hydration for missing conversation\`,{safe:{queuedNotificationCount:chatgptLinuxRemoteMobileQueued.length,attempt:chatgptLinuxRemoteMobileAttempt+1},sensitive:{conversationId:${conversationIdVar}}}),setTimeout(()=>chatgptLinuxRemoteMobileRead(chatgptLinuxRemoteMobileAttempt+1),250);return}` + + `${managerVar}.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),${managerVar}.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar}),${loggerExpression}.warning(\`Skipping hydration for missing conversation\`,{safe:{queuedNotificationCount:chatgptLinuxRemoteMobileQueued.length},sensitive:{conversationId:${conversationIdVar}}});return}` + + `${contextVar}.upsertConversationFromThread(chatgptLinuxRemoteMobileThread),${managerVar}.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),${managerVar}.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar});for(let chatgptLinuxRemoteMobileEvent of chatgptLinuxRemoteMobileQueued)${managerVar}.onNotification(chatgptLinuxRemoteMobileEvent.method,chatgptLinuxRemoteMobileEvent.params)},` + + `chatgptLinuxRemoteMobileError=>{if(chatgptLinuxRemoteMobileAttempt<12){${loggerExpression}.warning(\`Retrying hydration for ${eventName}\`,{safe:{attempt:chatgptLinuxRemoteMobileAttempt+1},sensitive:{conversationId:${conversationIdVar},error:chatgptLinuxRemoteMobileError}}),setTimeout(()=>chatgptLinuxRemoteMobileRead(chatgptLinuxRemoteMobileAttempt+1),250);return}` + + `${managerVar}.chatgptLinuxRemoteMobilePendingNotifications?.delete(${conversationIdVar}),${managerVar}.chatgptLinuxRemoteMobileInFlightHydrations?.delete(${conversationIdVar}),${loggerExpression}.error(\`Failed to hydrate conversation for ${eventName}\`,{safe:{},sensitive:{conversationId:${conversationIdVar},error:chatgptLinuxRemoteMobileError}})});` + + `chatgptLinuxRemoteMobileInFlight.add(${conversationIdVar}),chatgptLinuxRemoteMobileRead();break}` ); } function applyLinuxRemoteMobileConversationHydrationPatch(source) { let patched = source; - if (!patched.includes(REMOTE_MOBILE_THREAD_RUNTIME_MARKER)) { - const runtimeReplacement = - (_needle, conversationVar, runtimeVar) => - `/*${REMOTE_MOBILE_THREAD_RUNTIME_MARKER}*/(${conversationVar}.resumeState===\`needs_resume\`||${runtimeVar}?.type===\`active\`||${runtimeVar}?.type===\`idle\`)&&(${conversationVar}.threadRuntimeStatus=${runtimeVar})`; - const runtimeNeedle = - /([A-Za-z_$][\w$]*)\.resumeState===`needs_resume`&&\(\1\.threadRuntimeStatus=([A-Za-z_$][\w$]*)\)/u; - if (runtimeNeedle.test(patched)) { - patched = patched.replace(runtimeNeedle, runtimeReplacement); - } else if ( - patched.includes("threadRuntimeStatus:e.threadRuntimeStatus") && - patched.includes("t===`needs_resume`?n?.type===`active`") - ) { - // Current upstream preserves threadRuntimeStatus on thread summaries and - // already treats active needs-resume threads as live in the sidebar model. - } else if (patched.includes("threadRuntimeStatus") && patched.includes("resumeState")) { - console.warn("WARN: Could not find thread/list runtime-status needle - skipping remote mobile runtime-status patch"); - } + if ( + patched.includes("threadRuntimeStatus:e.threadRuntimeStatus") && + patched.includes("t===`needs_resume`?n?.type===`active`") + ) { + // Current upstream preserves threadRuntimeStatus on thread summaries and + // already treats active needs-resume threads as live in the sidebar model. + } else if (patched.includes("threadRuntimeStatus") && patched.includes("resumeState")) { + console.warn("WARN: Could not find thread/list runtime-status needle - skipping remote mobile runtime-status patch"); } // Hydrate on turn/started and queue later events while that read is in flight. if (!patched.includes(REMOTE_MOBILE_NOTIFICATION_QUEUE_MARKER)) { - const unknownTurnNeedle = - /(let\{threadId:([A-Za-z_$][\w$]*),turn:[A-Za-z_$][\w$]*\}=([A-Za-z_$][\w$]*)\.params,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\);)if\(!this\.conversations\.get\(\4\)\)\{([A-Za-z_$][\w$]*)\.error\(`Received turn\/started for unknown conversation`,\{safe:\{conversationId:\4\},sensitive:\{\}\}\);break\}/u; - const unknownTurnReplacement = - (_needle, prefix, _threadIdParamVar, notificationVar, conversationIdVar, normalizerFn, loggerVar) => - `${prefix}if(!this.conversations.get(${conversationIdVar})){/*${REMOTE_MOBILE_UNKNOWN_TURN_MARKER}*//*${REMOTE_MOBILE_NOTIFICATION_QUEUE_MARKER}*//*${REMOTE_MOBILE_IN_FLIGHT_HYDRATION_MARKER}*/let l=${notificationVar}.params?.turn?.threadId??${notificationVar}.params?.thread?.id,d=l!=null?${normalizerFn}(l):null,u=${notificationVar}.params?.turn?.id??${notificationVar}.params?.turnId;if(d==null||u!=null&&d===${normalizerFn}(u)){${loggerVar}.warning(\`Skipping hydration for ambiguous turn/started\`,{safe:{},sensitive:{conversationId:${conversationIdVar},resolvedConversationId:d,turnId:u??null}});break}${notificationVar}={...${notificationVar},params:{...${notificationVar}.params,threadId:l}};if(this.conversations.get(d)){this.onNotification(${notificationVar}.method,${notificationVar}.params);break}let i=this.chatgptLinuxRemoteMobilePendingNotifications??=new Map,a=i.get(d);a||(a=[],i.set(d,a));let p=u!=null?a.findIndex(e=>{let t=e.params?.turn?.id??e.params?.turnId;return e.method===${notificationVar}.method&&t!=null&&${normalizerFn}(t)===${normalizerFn}(u)}):-1;p>=0?a[p]=${notificationVar}:a.push(${notificationVar});let h=this.chatgptLinuxRemoteMobileInFlightHydrations??=new Set;if(h.has(d)){${loggerVar}.warning(\`Queueing turn/started for hydrating conversation\`,{safe:{queuedNotificationCount:a.length,dedupedNotification:p>=0},sensitive:{conversationId:d}});break}${loggerVar}.warning(\`Hydrating conversation for turn/started\`,{safe:{queuedNotificationCount:a.length},sensitive:{conversationId:d}});let o=(s=0)=>this.readThread(d,{includeTurns:!0}).then(e=>{let t=e?.thread??e,c=this.chatgptLinuxRemoteMobilePendingNotifications?.get(d)??[],chatgptLinuxRemoteMobileTurns=Array.isArray(e?.turns)?e.turns:Array.isArray(t?.turns)?t.turns:null;if(!t||!Array.isArray(chatgptLinuxRemoteMobileTurns)||chatgptLinuxRemoteMobileTurns.length===0){if(s<12){${loggerVar}.warning(\`Retrying hydration for missing conversation\`,{safe:{queuedNotificationCount:c.length,attempt:s+1},sensitive:{conversationId:d}}),setTimeout(()=>o(s+1),250);return}this.chatgptLinuxRemoteMobilePendingNotifications?.delete(d),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(d),${loggerVar}.warning(\`Skipping hydration for missing conversation\`,{safe:{queuedNotificationCount:c.length},sensitive:{conversationId:d}});return}this.upsertConversationFromThread(t),this.chatgptLinuxRemoteMobilePendingNotifications?.delete(d),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(d);for(let e of c)this.onNotification(e.method,e.params)}).catch(e=>{if(s<12){${loggerVar}.warning(\`Retrying hydration for turn/started\`,{safe:{attempt:s+1},sensitive:{conversationId:d,error:e}}),setTimeout(()=>o(s+1),250);return}this.chatgptLinuxRemoteMobilePendingNotifications?.delete(d),this.chatgptLinuxRemoteMobileInFlightHydrations?.delete(d),${loggerVar}.error(\`Failed to hydrate conversation for turn/started\`,{safe:{},sensitive:{conversationId:d,error:e}})});h.add(d),o();break}`; - if (unknownTurnNeedle.test(patched)) { - patched = patched.replace(unknownTurnNeedle, unknownTurnReplacement); - } else if (patched.includes("Received turn/started for unknown conversation")) { - console.warn("WARN: Could not find unknown turn/started needle - skipping remote mobile hydration patch"); + const currentUnknownTurnNeedle = + /(let\{threadId:([A-Za-z_$][\w$]*),turn:[A-Za-z_$][\w$]*\}=([A-Za-z_$][\w$]*)\.params,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\);)if\(!([A-Za-z_$][\w$]*)\.threadStore\.conversations\.get\(\4\)\)\{([A-Za-z_$][\w$]*)\.logger\.error\(`Received turn\/started for unknown conversation`,\{safe:\{conversationId:\4\},sensitive:\{\}\}\);break\}/u; + if (currentUnknownTurnNeedle.test(patched)) { + patched = patched.replace( + currentUnknownTurnNeedle, + (_needle, prefix, _threadIdVar, notificationVar, conversationIdVar, _normalizerFn, contextVar, managerVar) => + `${prefix}/*${REMOTE_MOBILE_UNKNOWN_TURN_MARKER}*/${buildCurrentUnknownConversationHydrationReplacement({ contextVar, conversationIdVar, eventName: "turn/started", loggerExpression: `${managerVar}.logger`, managerVar, notificationVar })}`, + ); } - const itemStartedNeedle = - /if\(!this\.conversations\.get\(([A-Za-z_$][\w$]*)\)\)\{([A-Za-z_$][\w$]*)\.error\(`Received item\/started for unknown conversation`,\{safe:\{conversationId:\1\},sensitive:\{\}\}\);break\}/u; - if (itemStartedNeedle.test(patched)) { - patched = patched.replace( - itemStartedNeedle, - (_needle, conversationIdVar, loggerVar) => - buildLateUnknownConversationHydrationReplacement("item/started", conversationIdVar, loggerVar), + const currentItemStartedNeedle = + /(let\{item:([A-Za-z_$][\w$]*),threadId:([A-Za-z_$][\w$]*)(?:,[^{}]*)?\}=([A-Za-z_$][\w$]*)\.params,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\3\);)if\(!([A-Za-z_$][\w$]*)\.threadStore\.conversations\.get\(\5\)\)\{([A-Za-z_$][\w$]*)\.logger\.error\(`Received item\/started for unknown conversation`,\{safe:\{conversationId:\5\},sensitive:\{\}\}\);break\}/u; + if (currentItemStartedNeedle.test(patched)) { + patched = patched.replace(currentItemStartedNeedle, (_needle, prefix, _itemVar, _threadIdVar, notificationVar, conversationIdVar, _normalizerFn, contextVar, managerVar) => + `${prefix}${buildCurrentUnknownConversationHydrationReplacement({ contextVar, conversationIdVar, eventName: "item/started", loggerExpression: `${managerVar}.logger`, managerVar, notificationVar })}`, ); - } else if (patched.includes("Received item/started for unknown conversation")) { - console.warn("WARN: Could not find unknown item/started needle - skipping remote mobile item queue patch"); } - const itemCompletedNeedle = - /if\(([^{};]*clearItemTerminalInputBuffer\([^{};]*\)),!this\.conversations\.get\(([A-Za-z_$][\w$]*)\)\)\{([A-Za-z_$][\w$]*)\.error\(`Received item\/completed for unknown conversation`,\{safe:\{conversationId:\2\},sensitive:\{\}\}\);break\}/u; - if (itemCompletedNeedle.test(patched)) { - patched = patched.replace( - itemCompletedNeedle, - (_needle, completionPrelude, conversationIdVar, loggerVar) => - `${completionPrelude};${buildLateUnknownConversationHydrationReplacement("item/completed", conversationIdVar, loggerVar)}`, + const currentItemCompletedNeedle = + /(let\{item:([A-Za-z_$][\w$]*),threadId:([A-Za-z_$][\w$]*)(?:,[^{}]*)?\}=([A-Za-z_$][\w$]*)\.params(?:,|;[\s\S]*?let )([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\3\);)if\(([^{};]*clearItemTerminalInputBuffer\([^{};]*\)),!([A-Za-z_$][\w$]*)\.threadStore\.conversations\.get\(\5\)\)\{([A-Za-z_$][\w$]*)\.logger\.error\(`Received item\/completed for unknown conversation`,\{safe:\{conversationId:\5\},sensitive:\{\}\}\);break\}/u; + if (currentItemCompletedNeedle.test(patched)) { + patched = patched.replace(currentItemCompletedNeedle, (_needle, prefix, _itemVar, _threadIdVar, notificationVar, conversationIdVar, _normalizerFn, prelude, contextVar, managerVar) => + `${prefix}${prelude};${buildCurrentUnknownConversationHydrationReplacement({ contextVar, conversationIdVar, eventName: "item/completed", loggerExpression: `${managerVar}.logger`, managerVar, notificationVar })}`, ); - } else if (patched.includes("Received item/completed for unknown conversation")) { - console.warn("WARN: Could not find unknown item/completed needle - skipping remote mobile item queue patch"); } - const turnCompletedNeedle = - /if\(!this\.conversations\.get\(([A-Za-z_$][\w$]*)\)\)\{([^{};]*),([A-Za-z_$][\w$]*)\.error\(`Received turn\/completed for unknown conversation`,\{safe:\{conversationId:\1\},sensitive:\{\}\}\);break\}/u; - const turnCompletedReplacement = - (_needle, conversationIdVar, completionPrelude, loggerVar) => - buildLateUnknownConversationHydrationReplacement( - "turn/completed", - conversationIdVar, - loggerVar, - completionPrelude, - ); - if (turnCompletedNeedle.test(patched)) { - patched = patched.replace(turnCompletedNeedle, turnCompletedReplacement); - } else if (patched.includes("Received turn/completed for unknown conversation")) { - console.warn("WARN: Could not find unknown turn/completed needle - skipping remote mobile turn queue patch"); + const currentTurnCompletedNeedle = + /(let\{threadId:([A-Za-z_$][\w$]*),turn:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\.params,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\);)if\(!([A-Za-z_$][\w$]*)\.threadStore\.conversations\.get\(\5\)\)\{((?:[^{};]*),\7\.unread\.discardTurn\([^{};]*\)),([A-Za-z_$][\w$]*)\.logger\.error\(`Received turn\/completed for unknown conversation`,\{safe:\{conversationId:\5\},sensitive:\{\}\}\);break\}/u; + if (currentTurnCompletedNeedle.test(patched)) { + patched = patched.replace(currentTurnCompletedNeedle, (_needle, prefix, _threadIdVar, _turnVar, notificationVar, conversationIdVar, _normalizerFn, contextVar, prelude, managerVar) => + `${prefix}${buildCurrentUnknownConversationHydrationReplacement({ contextVar, conversationIdVar, eventName: "turn/completed", loggerExpression: `${managerVar}.logger`, managerVar, notificationVar, prelude })}`, + ); + } + + let hydrationComplete = true; + for (const [eventName, message] of [ + ["turn/started", "unknown turn/started needle"], + ["item/started", "unknown item/started needle"], + ["item/completed", "unknown item/completed needle"], + ["turn/completed", "unknown turn/completed needle"], + ]) { + if (patched.includes(`Received ${eventName} for unknown conversation`)) { + hydrationComplete = false; + console.warn(`WARN: Could not find ${message} - skipping remote mobile hydration patch`); + } + } + if (!hydrationComplete) { + return source; + } + if (patched.includes(REMOTE_MOBILE_UNKNOWN_TURN_MARKER)) { + patched = patched.replace( + `/*${REMOTE_MOBILE_UNKNOWN_TURN_MARKER}*/`, + `/*${REMOTE_MOBILE_UNKNOWN_TURN_MARKER}*//*${REMOTE_MOBILE_NOTIFICATION_QUEUE_MARKER}*//*${REMOTE_MOBILE_IN_FLIGHT_HYDRATION_MARKER}*/`, + ); } } @@ -748,23 +743,13 @@ function applyLinuxRemoteMobileCompletedItemRecoveryPatch(source) { return source; } - const completedItemDropPattern = - /([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)&&\(([A-Za-z_$][\w$]*)\.firstTurnWorkItemStartedAtMs=\3\.firstTurnWorkItemStartedAtMs\?\?Date\.now\(\)\),!\(\2\.type!==`subAgentActivity`&&\(\2\.type!==`sleep`\|\|([A-Za-z_$][\w$]*)\.mode!==`durable`\)&&!([A-Za-z_$][\w$]*)\(\3,\2\.id,\2\.type\)\)&&\(\2\.type,([A-Za-z_$][\w$]*)\(\3,([A-Za-z_$][\w$]*)\)\)/u; - - if (completedItemDropPattern.test(source)) { + const currentCompletedItemDropPattern = + /([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)&&\(([A-Za-z_$][\w$]*)\.firstTurnWorkItemStartedAtMs=\3\.firstTurnWorkItemStartedAtMs\?\?Date\.now\(\)\),!\(\2\.type!==`subAgentActivity`&&\(\2\.type!==`sleep`\|\|([A-Za-z_$][\w$]*)\.mode!==`durable`\)&&!([A-Za-z_$][\w$]*)\(\3,\2\.id,\2\.type,([A-Za-z_$][\w$]*\.logger)\)\)&&\(\2\.type,([A-Za-z_$][\w$]*)\(\3,([A-Za-z_$][\w$]*)\)\)/u; + if (currentCompletedItemDropPattern.test(source)) { return source.replace( - completedItemDropPattern, - ( - _match, - workItemPredicate, - completedItemVar, - turnVar, - conversationVar, - findItemFn, - upsertItemFn, - viewItemVar, - ) => - `${workItemPredicate}(${completedItemVar})&&(${turnVar}.firstTurnWorkItemStartedAtMs=${turnVar}.firstTurnWorkItemStartedAtMs??Date.now());let chatgptLinuxCompletedItemExists=${turnVar}.items.some(e=>e.id===${viewItemVar}.id);if(${completedItemVar}.type!==\`subAgentActivity\`&&(${completedItemVar}.type!==\`sleep\`||${conversationVar}.mode!==\`durable\`)&&chatgptLinuxCompletedItemExists&&!${findItemFn}(${turnVar},${completedItemVar}.id,${completedItemVar}.type))return;${upsertItemFn}(${turnVar},${viewItemVar})`, + currentCompletedItemDropPattern, + (_match, workItemPredicate, completedItemVar, turnVar, conversationVar, findItemFn, loggerExpression, upsertItemFn, viewItemVar) => + `${workItemPredicate}(${completedItemVar})&&(${turnVar}.firstTurnWorkItemStartedAtMs=${turnVar}.firstTurnWorkItemStartedAtMs??Date.now());let chatgptLinuxCompletedItemExists=${turnVar}.items.some(chatgptLinuxCompletedItemCandidate=>chatgptLinuxCompletedItemCandidate.id===${viewItemVar}.id);if(${completedItemVar}.type!==\`subAgentActivity\`&&(${completedItemVar}.type!==\`sleep\`||${conversationVar}.mode!==\`durable\`)&&chatgptLinuxCompletedItemExists&&!${findItemFn}(${turnVar},${completedItemVar}.id,${completedItemVar}.type,${loggerExpression}))return;${upsertItemFn}(${turnVar},${viewItemVar})`, ); } diff --git a/port-integrations/remote-mobile-control/test.js b/port-integrations/remote-mobile-control/test.js index ffbfa5d8d..fb0432fe3 100644 --- a/port-integrations/remote-mobile-control/test.js +++ b/port-integrations/remote-mobile-control/test.js @@ -112,21 +112,11 @@ test("remote mobile README assigns every descriptor to one control topology", () }); function syntheticMainBundle() { - return [ - 'let i=require("node:path"),o=require("node:fs"),s=require("node:crypto"),h=require("node:child_process"),b={createRequire:()=>()=>({})};', - "function TV(e){return Buffer.from(JSON.stringify(e),`utf8`)}", - "var bV=(0,b.createRequire)(__filename),xV=`remote-control-device-key.node`,SV=`codex-device-key-sign-payload/v1`;", - "function wV({resourcesPath:e}){let t=null,n=()=>{if(process.platform!==`darwin`)throw Error(`Remote control device keys are only available on macOS`);if(e==null)throw Error(`Remote control device keys require resourcesPath`);return t??=bV(i.join(e,`native`,xV)),t};return{createDeviceKey:e=>n().createDeviceKey(e??`hardware_only`),deleteDeviceKey:e=>n().deleteDeviceKey(e),getDeviceKeyPublic:e=>n().getDeviceKeyPublic(e),signDeviceKey:async(e,t)=>{let r=TV(t);return{...await n().signDeviceKey(e,r),signedPayloadBase64:r.toString(`base64`)}}}}", - "async function mV({codexHome:e,hostConfig:n,logger:r=t.Jr()}){if(n.kind===`local`)try{await hV(i.default.join(e??t.Rr({hostConfig:n,preferWsl:t.Kr(n)}),pV))&&r.info(`Removed remote_control from config before app-server start`)}catch(e){r.warning(`Failed to remove remote_control before app-server start`,{safe:{},sensitive:{error:e}})}}", - ].join(""); -} - -function syntheticCurrentMainBundle() { return [ 'let i=require("node:path"),o=require("node:fs"),s=require("node:crypto"),h=require("node:child_process"),b={createRequire:()=>()=>({})};', "function mz(e){return Buffer.from(JSON.stringify({domain:`codex-device-key-sign-payload/v1`,payload:e}),`utf8`)}", "var lz=(0,b.createRequire)(__filename),uz=`remote-control-device-key.node`,dz=`codex-device-key-sign-payload/v1`;", - "function pz({resourcesPath:e}){let t=null,n=()=>{if(process.platform!==`darwin`)throw Error(`Remote control device keys are only available on macOS`);if(e==null)throw Error(`Remote control device keys require resourcesPath`);return t??=lz((0,i.join)(e,`native`,uz)),t};return{createDeviceKey:e=>n().createDeviceKey(e??`hardware_only`),deleteDeviceKey:e=>n().deleteDeviceKey(e),getDeviceKeyPublic:e=>n().getDeviceKeyPublic(e),signDeviceKey:async(e,t)=>{let r=mz(t);return{...await n().signDeviceKey(e,r),signedPayloadBase64:r.toString(`base64`)}}}}", + "function pz({resourcesPath:e}){let t=null,n=()=>{if(process.platform!==`darwin`&&process.platform!==`win32`)throw Error(`Remote control device keys are only available on macOS and Windows`);if(e==null)throw Error(`Remote control device keys require resourcesPath`);return t??=lz((0,i.join)(e,`native`,uz)),t};return{createDeviceKey:e=>n().createDeviceKey(e??`hardware_only`),deleteDeviceKey:e=>n().deleteDeviceKey(e),getDeviceKeyPublic:e=>n().getDeviceKeyPublic(e),signDeviceKey:async(e,t)=>{let r=mz(t);return{...await n().signDeviceKey(e,r),signedPayloadBase64:r.toString(`base64`)}}}}", "async function vV({codexHome:e,hostConfig:n,logger:r=t.Jr()}){if(n.kind===`local`)try{await yV(i.default.join(e??t.Rr({hostConfig:n,preferWsl:t.Kr(n)}),_V))&&r.info(`Removed remote_control from config before app-server start`)}catch(e){r.warning(`Failed to remove remote_control before app-server start`,{safe:{},sensitive:{error:e}})}}", ].join(""); } @@ -136,7 +126,7 @@ function syntheticCryptoAliasCollisionMainBundle() { 'let a=require("node:path"),o=require("node:fs"),c=require("node:crypto"),h=require("node:child_process"),b={createRequire:()=>()=>({})};', "function mz(e){return Buffer.from(JSON.stringify({domain:`codex-device-key-sign-payload/v1`,payload:e}),`utf8`)}", "var lz=(0,b.createRequire)(__filename),uz=`remote-control-device-key.node`,dz=`codex-device-key-sign-payload/v1`;", - "function pz({resourcesPath:e}){let t=null,n=()=>{if(process.platform!==`darwin`)throw Error(`Remote control device keys are only available on macOS`);if(e==null)throw Error(`Remote control device keys require resourcesPath`);return t??=lz((0,a.join)(e,`native`,uz)),t};return{createDeviceKey:e=>n().createDeviceKey(e??`hardware_only`),deleteDeviceKey:e=>n().deleteDeviceKey(e),getDeviceKeyPublic:e=>n().getDeviceKeyPublic(e),signDeviceKey:async(e,t)=>{let r=mz(t);return{...await n().signDeviceKey(e,r),signedPayloadBase64:r.toString(`base64`)}}}}", + "function pz({resourcesPath:e}){let t=null,n=()=>{if(process.platform!==`darwin`&&process.platform!==`win32`)throw Error(`Remote control device keys are only available on macOS and Windows`);if(e==null)throw Error(`Remote control device keys require resourcesPath`);return t??=lz((0,a.join)(e,`native`,uz)),t};return{createDeviceKey:e=>n().createDeviceKey(e??`hardware_only`),deleteDeviceKey:e=>n().deleteDeviceKey(e),getDeviceKeyPublic:e=>n().getDeviceKeyPublic(e),signDeviceKey:async(e,t)=>{let r=mz(t);return{...await n().signDeviceKey(e,r),signedPayloadBase64:r.toString(`base64`)}}}}", ].join(""); } @@ -161,7 +151,7 @@ function createPatchedDeviceKeyClient(configHome, moduleOverrides = {}, processE require: (moduleName) => moduleOverrides[moduleName] ?? require(moduleName), setTimeout, }; - vm.runInNewContext(`${patched};module.exports=wV({resourcesPath:null});`, context); + vm.runInNewContext(`${patched};module.exports=pz({resourcesPath:null});`, context); return context.module.exports; } @@ -359,31 +349,22 @@ function syntheticModernChromeBrowserClientBundle() { function syntheticAppServerManagerSignalsBundle() { return [ - "function Of({conversationId:e,conversations:t,getWorkspaceBrowserRoot:n,getWorkspaceKind:r,hostId:i,setConversation:a,thread:o,threadsById:s,updateConversationState:c}){let h=o.status??null;if(t.has(e)){c(e,e=>{e.resumeState===`needs_resume`&&(e.threadRuntimeStatus=h)});return}}", - "function cleanup(){}class T{unread={discardTurn(){}};itemStreamState={clearItemTerminalInputBuffer(){}};onNotification(e,t){let n={method:e,params:t};switch(n.method){case`turn/started`:{let{threadId:e,turn:t}=n.params,r=I(e);if(!this.conversations.get(r)){z.error(`Received turn/started for unknown conversation`,{safe:{conversationId:r},sensitive:{}});break}this.markConversationStreaming(r),this.updateConversationState(r,e=>{});break}case`turn/completed`:{if(this.frameTextDeltaQueue.drainBefore(()=>{this.onNotification(`turn/completed`,n.params)}))break;let{threadId:e,turn:t}=n.params,r=I(e);if(!this.conversations.get(r)){cleanup(this.hostId,e,t.id),this.unread.discardTurn(r,t.id),z.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:r},sensitive:{}});break}break}case`item/started`:{let{item:e,threadId:t,turnId:r,startedAtMs:i}=n.params,a=I(t);if(!this.conversations.get(a)){z.error(`Received item/started for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}this.markConversationStreaming(a),this.updateConversationState(a,t=>{});break}case`item/completed`:{if(this.frameTextDeltaQueue.drainBefore(()=>{this.onNotification(`item/completed`,n.params)}))break;let{item:e,threadId:t,turnId:r,completedAtMs:i}=n.params,a=I(t);if(e.type===`commandExecution`&&this.itemStreamState.clearItemTerminalInputBuffer(a,e.id),!this.conversations.get(a)){z.error(`Received item/completed for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}this.updateConversationState(a,t=>{});break}}}}", + "const currentThreadRuntimeStatusModel=`threadRuntimeStatus:e.threadRuntimeStatus`,currentNeedsResumeModel=\"t===`needs_resume`?n?.type===`active`\";", + "function cleanup(){}function hLn(e,t,n,r){let i={method:n,params:r};switch(i.method){case`turn/started`:{let{threadId:n,turn:r}=i.params,a=I(n);if(!t.threadStore.conversations.get(a)){e.logger.error(`Received turn/started for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}e.markConversationStreaming(a),e.updateConversationState(a,e=>{});break}case`turn/completed`:{let{threadId:n,turn:r}=i.params,a=I(n);if(!t.threadStore.conversations.get(a)){cleanup(e.getHostId(),n,r.id),t.unread.discardTurn(a,r.id),e.logger.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}e.updateConversationState(a,e=>{});break}case`item/started`:{let{item:n,threadId:r}=i.params,s=I(r);if(!t.threadStore.conversations.get(s)){e.logger.error(`Received item/started for unknown conversation`,{safe:{conversationId:s},sensitive:{}});break}e.markConversationStreaming(s),e.updateConversationState(s,e=>{});break}case`item/completed`:{let{item:n,threadId:r}=i.params,s=I(r);if(n.type===`commandExecution`&&t.itemStreamState.clearItemTerminalInputBuffer(s,n.id),!t.threadStore.conversations.get(s)){e.logger.error(`Received item/completed for unknown conversation`,{safe:{conversationId:s},sensitive:{}});break}e.updateConversationState(s,e=>{});break}}}", + "class T{unread={discardTurn(){}};itemStreamState={clearItemTerminalInputBuffer(){}};logger=z;getHostId(){return this.hostId}get notificationContext(){return{threadStore:{conversations:this.conversations},itemStreamState:this.itemStreamState,unread:this.unread,upsertConversationFromThread:this.upsertConversationFromThread.bind(this)}}onNotification(e,t){hLn(this,this.notificationContext,e,t)}}", ].join(""); } -function syntheticCompletedItemRecoveryBundle() { - return [ - "class U{onNotification(e,t){let n={method:e,params:t};switch(n.method){case`item/completed`:{if(this.frameTextDeltaQueue.drainBefore(()=>{this.onNotification(`item/completed`,n.params)}))break;", - "let{item:e,threadId:t,turnId:r,completedAtMs:i}=n.params,a=qf(t);if(!this.conversations.get(a)){$.error(`Received item/completed for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}", - "this.updateConversationState(a,t=>{let n=e.type===`userMessage`?gI(t,r):r==null?uI(t):fI(t,e=>e.turnId===r);if(!n)return;aR(n);", - "let a=Jtt({item:e,threadsById:this.threadStore.threadsById,onCollabAgentToolCall:e=>{this.hydrateCollabThreads(e.receiverThreadIds)}}),o=a.type===`contextCompaction`?n.items.find(e=>e.type===`contextCompaction`&&e.id===a.id):null;", - "if(a.type===`commandExecution`){let e=a.durationMs==null?null:i-a.durationMs;e!=null&&(n.commandExecutionStartedAtMsById??={},n.commandExecutionStartedAtMsById[a.id]??=e)}", - "let s=FF(a.type===`contextCompaction`?{...a,completed:!0,source:o?.type===`contextCompaction`&&`source`in o?o.source:`automatic`}:a);", - "if(e.type===`userMessage`){let t=Put(n.items,e.content,n.turnId,n.turnStartedAtMs,!1);if(t!=null){t.status=`accepted`,HI(n,FF({type:`steered`,id:e.id}));return}HI(n,s);return}", - "if(e.type===`hookPrompt`){bP(n,s);return}", - "yV(e)&&(n.firstTurnWorkItemStartedAtMs=n.firstTurnWorkItemStartedAtMs??Date.now()),!(e.type!==`subAgentActivity`&&(e.type!==`sleep`||t.mode!==`durable`)&&!LB(n,e.id,e.type))&&(e.type,bP(n,s))});break}}}}", - ].join(""); +function syntheticCurrentNotificationHandlerBundle() { + return "function hLn(e,t,n,r){let i={method:n,params:r};switch(i.method){case`turn/started`:{let{threadId:n,turn:r}=i.params,a=Wl(n);if(!t.threadStore.conversations.get(a)){e.logger.error(`Received turn/started for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}break}case`item/started`:{let{item:n,threadId:r}=i.params,s=Wl(r);if(!t.threadStore.conversations.get(s)){e.logger.error(`Received item/started for unknown conversation`,{safe:{conversationId:s},sensitive:{}});break}break}case`item/completed`:{let{item:n,threadId:r}=i.params,s=Wl(r);if(n.type===`commandExecution`&&t.itemStreamState.clearItemTerminalInputBuffer(s,n.id),!t.threadStore.conversations.get(s)){e.logger.error(`Received item/completed for unknown conversation`,{safe:{conversationId:s},sensitive:{}});break}break}case`turn/completed`:{let{threadId:n,turn:r}=i.params,a=Wl(n);if(!t.threadStore.conversations.get(a)){LIn(e.getHostId(),n,r.id),t.unread.discardTurn(a,r.id),e.logger.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}break}}}"; } -function syntheticLatestCompletedItemRecoveryBundle() { +function syntheticCompletedItemRecoveryBundle() { return [ "class U{onNotification(e,t){let n={method:e,params:t};switch(n.method){case`item/completed`:{let{item:e,threadId:t,turnId:r}=n.params,a=qf(t);", "this.updateConversationState(a,t=>{let n=yC(t,e=>e.turnId===r);if(!n)return;let a=nun({item:e,threadsById:this.threadStore.threadsById}),s=aC(a);", - "LQn(e)&&(n.firstTurnWorkItemStartedAtMs=n.firstTurnWorkItemStartedAtMs??Date.now()),!(e.type!==`subAgentActivity`&&(e.type!==`sleep`||t.mode!==`durable`)&&!aD(n,e.id,e.type))&&(e.type,AC(n,s))});break}", - "case`item/agentMessage/delta`:break}}}}", + "LQn(e)&&(n.firstTurnWorkItemStartedAtMs=n.firstTurnWorkItemStartedAtMs??Date.now()),!(e.type!==`subAgentActivity`&&(e.type!==`sleep`||t.mode!==`durable`)&&!aD(n,e.id,e.type,$.logger))&&(e.type,AC(n,s))});break}", + "case`item/agentMessage/delta`:break}}}", "const diagnostic=`Item not found in turn state`;", ].join(""); } @@ -1205,15 +1186,15 @@ test("Linux remote-control feature patch updates the device-key provider", () => assert.equal(applyLinuxRemoteControlDeviceKeyPatch(patched), patched); }); -test("Linux remote-control device-key patch handles current minified aliases", () => { - const source = syntheticCurrentMainBundle(); - const patched = applyLinuxRemoteControlDeviceKeyPatch(source); +test("Linux remote-control device-key patch fails closed when the current guard drifts", () => { + const source = syntheticMainBundle().replace( + "Remote control device keys are only available on macOS and Windows", + "Remote control device keys are unavailable on this platform", + ); + const { result, warnings } = captureWarnings(() => applyLinuxRemoteControlDeviceKeyPatch(source)); - assert.notEqual(patched, source); - assert.match(patched, /chatgptLinuxRemoteControlDeviceKeyClient/); - assert.match(patched, /process\.platform===`linux`\)return chatgptLinuxRemoteControlDeviceKeyClient\(\)/); - assert.doesNotMatch(patched, /n\.kind===`local`&&process\.platform!==`linux`/); - assert.equal(applyLinuxRemoteControlDeviceKeyPatch(patched), patched); + assert.equal(result, source); + assert.ok(warnings.some((warning) => warning.includes("device-key bundle needles"))); }); test("Linux remote-control device-key provider does not capture a function-local child-process alias", () => { @@ -1739,104 +1720,174 @@ test("Linux remote mobile conversation hydration patch handles current app-serve const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); assert.notEqual(patched, source); - assert.match(patched, /chatgptLinuxRemoteMobileThreadRuntimeStatus/); - assert.match(patched, /h\?\.type===`active`\|\|h\?\.type===`idle`/); + assert.doesNotMatch(patched, /chatgptLinuxRemoteMobileThreadRuntimeStatus/); + assert.match(patched, /threadRuntimeStatus:e\.threadRuntimeStatus/); + assert.match(patched, /t===`needs_resume`\?n\?\.type===`active`/); assert.match(patched, /chatgptLinuxRemoteMobileHydrateUnknownTurn/); assert.match(patched, /chatgptLinuxRemoteMobileNotificationQueue/); assert.match(patched, /chatgptLinuxRemoteMobileHydrationInFlight/); - assert.match(patched, /n\.params\?\.turn\?\.threadId\?\?n\.params\?\.thread\?\.id/); - assert.doesNotMatch(patched, /n\.params\?\.threadId/); - assert.match(patched, /Skipping hydration for ambiguous turn\/started/); assert.match(patched, /chatgptLinuxRemoteMobilePendingNotifications\?\?=new Map/); assert.match(patched, /chatgptLinuxRemoteMobileInFlightHydrations\?\?=new Set/); - assert.match(patched, /dedupedNotification:p>=0/); - assert.match(patched, /this\.readThread\(d,\{includeTurns:!0\}\)/); + assert.match(patched, /e\.readThread\(a,\{includeTurns:!0\}\)/); assert.match(patched, /Hydrating conversation for turn\/started/); assert.match(patched, /Queueing turn\/started for hydrating conversation/); - assert.match(patched, /this\.upsertConversationFromThread\(t\)/); - assert.match(patched, /this\.chatgptLinuxRemoteMobileInFlightHydrations\?\.delete\(d\)/); - assert.match(patched, /for\(let e of c\)this\.onNotification\(e\.method,e\.params\)/); + assert.match(patched, /t\.upsertConversationFromThread\(chatgptLinuxRemoteMobileThread\)/); + assert.match(patched, /e\.chatgptLinuxRemoteMobileInFlightHydrations\?\.delete\(a\)/); + assert.match(patched, /for\(let chatgptLinuxRemoteMobileEvent of chatgptLinuxRemoteMobileQueued\)e\.onNotification/); assert.match(patched, /Queueing item\/started for hydrating conversation/); assert.match(patched, /Queueing item\/completed for hydrating conversation/); assert.match(patched, /Queueing turn\/completed for hydrating conversation/); - assert.doesNotMatch(patched, /safe:\{[^}]*\b(?:conversationId|resolvedConversationId|turnId):/); - assert.match(patched, /sensitive:\{conversationId:[^}]+resolvedConversationId:[^}]+turnId:/); + assert.doesNotMatch(patched, /safe:\{[^}]*\bconversationId:/); assert.match(patched, /sensitive:\{conversationId:[^}]+error:/); + assert.doesNotMatch(patched, /if\(!this\.conversations/); assert.doesNotMatch(patched, /captureBrowserUseTurnRoute/); assert.doesNotMatch(patched, /releaseBrowserUseTurnRoute/); assert.equal(applyLinuxRemoteMobileConversationHydrationPatch(patched), patched); }); -test("Linux remote mobile hydration skips turn ids before reading threads", () => { +test("Linux remote mobile conversation hydration fails closed when current storage ownership drifts", () => { + const source = syntheticCurrentNotificationHandlerBundle().replaceAll( + ".threadStore.conversations", + ".threadCache.conversations", + ); + const { result, warnings } = captureWarnings(() => + applyLinuxRemoteMobileConversationHydrationPatch(source), + ); + + assert.equal(result, source); + assert.equal(warnings.filter((warning) => warning.includes("unknown ")).length, 4); +}); + +test("Linux remote mobile hydration reads the explicit current thread id", async () => { const source = syntheticAppServerManagerSignalsBundle(); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); const context = { module: { exports: {} }, I: (value) => value, + setTimeout, z: { error() {}, warning() {} }, }; vm.runInNewContext(`${patched};module.exports=T;`, context); const manager = new context.module.exports(); manager.conversations = new Map(); - manager.readThread = () => { - throw new Error("readThread should not be called for ambiguous turn ids"); + const readThreadIds = []; + manager.readThread = async (threadId) => { + readThreadIds.push(threadId); + return { thread: { id: threadId }, turns: [{ id: "turn-a" }] }; }; + manager.upsertConversationFromThread = (thread) => manager.conversations.set(thread.id, thread); + manager.markConversationStreaming = () => {}; + manager.updateConversationState = () => {}; manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a" }, }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(readThreadIds, ["thread-a"]); }); -test("Linux remote mobile hydration uses captured turn id normalizer helper", () => { +test("Linux remote mobile hydration uses the captured current thread id normalizer", async () => { const source = syntheticAppServerManagerSignalsBundle().replaceAll("I(", "J("); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); - assert.match(patched, /J\(l\)/); - assert.match(patched, /J\(u\)/); - assert.doesNotMatch(patched, /I\(l\)/); - assert.doesNotMatch(patched, /I\(u\)/); + assert.match(patched, /a=J\(n\)/); + assert.match(patched, /s=J\(r\)/); + assert.doesNotMatch(patched, /[as]=I\([nr]\)/); const context = { module: { exports: {} }, J: (value) => value, + setTimeout, z: { error() {}, warning() {} }, }; vm.runInNewContext(`${patched};module.exports=T;`, context); const manager = new context.module.exports(); manager.conversations = new Map(); - manager.readThread = () => { - throw new Error("readThread should not be called for ambiguous turn ids"); + const readThreadIds = []; + manager.readThread = async (threadId) => { + readThreadIds.push(threadId); + return { thread: { id: threadId }, turns: [{ id: "turn-a" }] }; }; + manager.upsertConversationFromThread = (thread) => manager.conversations.set(thread.id, thread); + manager.markConversationStreaming = () => {}; + manager.updateConversationState = () => {}; manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a" }, }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(readThreadIds, ["thread-a"]); }); -test("Linux remote mobile hydration ignores top-level thread ids without nested thread identity", () => { - const source = syntheticAppServerManagerSignalsBundle(); +test("Linux remote mobile hydration uses the captured notification variable", () => { + const source = syntheticAppServerManagerSignalsBundle() + .replace("let i={method:n,params:r};switch(i.method)", "let q={method:n,params:r};switch(q.method)") + .replaceAll("=i.params", "=q.params"); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); + + assert.equal((patched.match(/chatgptLinuxRemoteMobileQueue\.push\(q\)/gu) ?? []).length, 4); + assert.doesNotMatch(patched, /chatgptLinuxRemoteMobileQueue\.push\(i\)/u); +}); + +test("Linux remote mobile hydration bounds each pending notification queue", () => { + const patched = applyLinuxRemoteMobileConversationHydrationPatch( + syntheticAppServerManagerSignalsBundle(), + ); + const warnings = []; const context = { module: { exports: {} }, I: (value) => value, - z: { error() {}, warning() {} }, + setTimeout, + z: { error() {}, warning: (message) => warnings.push(String(message)) }, }; vm.runInNewContext(`${patched};module.exports=T;`, context); const manager = new context.module.exports(); manager.conversations = new Map(); - manager.readThread = () => { - throw new Error("readThread should not be called without nested thread identity"); - }; + manager.upsertConversationFromThread = () => {}; + manager.chatgptLinuxRemoteMobilePendingNotifications = new Map([ + ["thread-a", Array.from({ length: 512 }, () => ({ method: "queued", params: {} }))], + ]); + manager.chatgptLinuxRemoteMobileInFlightHydrations = new Set(["thread-a"]); - manager.onNotification("turn/started", { + manager.onNotification("item/started", { + item: { id: "item-a" }, threadId: "thread-a", - turn: { id: "turn-a" }, }); + + assert.equal( + manager.chatgptLinuxRemoteMobilePendingNotifications.get("thread-a").length, + 512, + ); + assert.ok(warnings.some((warning) => warning.includes("Dropping item/started"))); +}); + +test("Linux remote mobile hydration retries only read failures", () => { + const patched = applyLinuxRemoteMobileConversationHydrationPatch( + syntheticAppServerManagerSignalsBundle(), + ); + assert.match(patched, /\.then\(chatgptLinuxRemoteMobileResult=>\{/u); + assert.match(patched, /\},chatgptLinuxRemoteMobileError=>\{/u); + assert.doesNotMatch(patched, /\.catch\(chatgptLinuxRemoteMobileError/u); }); -test("Linux remote mobile hydration uses nested real thread ids", async () => { +test("Linux remote mobile hydration ignores decoy current messages with a different logger owner", () => { + const source = syntheticCurrentNotificationHandlerBundle().replaceAll( + "e.logger.error(`Received", + "e.audit.error(`Received", + ); + const { result, warnings } = captureWarnings(() => + applyLinuxRemoteMobileConversationHydrationPatch(source), + ); + + assert.equal(result, source); + assert.equal(warnings.filter((warning) => warning.includes("unknown ")).length, 4); +}); + +test("Linux remote mobile hydration uses normalized current thread ids", async () => { const source = syntheticAppServerManagerSignalsBundle(); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); const context = { @@ -1863,8 +1914,8 @@ test("Linux remote mobile hydration uses nested real thread ids", async () => { manager.updateConversationState = () => {}; manager.onNotification("turn/started", { - threadId: "turn-a", - turn: { id: "turn-a", threadId: "thread-a" }, + threadId: "thread-a", + turn: { id: "turn-a", threadId: "decoy-thread" }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -1894,6 +1945,7 @@ test("Linux remote mobile hydration recovers when a completed turn is the first manager.upsertConversationFromThread = (thread) => { manager.conversations.set(thread.id, thread); }; + manager.updateConversationState = () => {}; manager.onNotification("turn/completed", { threadId: "thread-a", @@ -2048,10 +2100,10 @@ test("Linux remote mobile hydration restarts when a pending queue exists without assert.equal(manager.chatgptLinuxRemoteMobilePendingNotifications?.has("thread-a"), false); assert.equal(manager.chatgptLinuxRemoteMobileInFlightHydrations?.has("thread-a"), false); - assert.deepEqual(updatedConversations, ["thread-a"]); + assert.deepEqual(updatedConversations, ["thread-a", "thread-a"]); }); -test("Linux remote mobile hydration dedupes concurrent unknown turn reads", async () => { +test("Linux remote mobile hydration uses one read for concurrent current notifications", async () => { const source = syntheticAppServerManagerSignalsBundle(); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); const context = { @@ -2082,11 +2134,11 @@ test("Linux remote mobile hydration dedupes concurrent unknown turn reads", asyn manager.updateConversationState = () => {}; manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a", threadId: "thread-a" }, }); manager.onNotification("turn/started", { - threadId: "turn-b", + threadId: "thread-a", turn: { id: "turn-b", threadId: "thread-a" }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -2103,7 +2155,7 @@ test("Linux remote mobile hydration dedupes concurrent unknown turn reads", asyn assert.deepEqual(streamed, ["thread-a", "thread-a"]); }); -test("Linux remote mobile hydration coalesces duplicate pending turn starts", async () => { +test("Linux remote mobile hydration preserves duplicate current notifications", async () => { const source = syntheticAppServerManagerSignalsBundle(); const patched = applyLinuxRemoteMobileConversationHydrationPatch(source); const context = { @@ -2134,19 +2186,19 @@ test("Linux remote mobile hydration coalesces duplicate pending turn starts", as manager.updateConversationState = () => {}; manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a", threadId: "thread-a", marker: "first" }, }); manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a", threadId: "thread-a", marker: "latest" }, }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(readThreadIds, ["thread-a"]); - assert.equal(manager.chatgptLinuxRemoteMobilePendingNotifications.get("thread-a").length, 1); + assert.equal(manager.chatgptLinuxRemoteMobilePendingNotifications.get("thread-a").length, 2); assert.equal( - manager.chatgptLinuxRemoteMobilePendingNotifications.get("thread-a")[0].params.turn.marker, + manager.chatgptLinuxRemoteMobilePendingNotifications.get("thread-a")[1].params.turn.marker, "latest", ); @@ -2155,7 +2207,7 @@ test("Linux remote mobile hydration coalesces duplicate pending turn starts", as assert.equal(manager.chatgptLinuxRemoteMobilePendingNotifications.has("thread-a"), false); assert.equal(manager.chatgptLinuxRemoteMobileInFlightHydrations.has("thread-a"), false); - assert.deepEqual(streamed, ["thread-a"]); + assert.deepEqual(streamed, ["thread-a", "thread-a"]); }); test("Linux remote mobile hydration does not coalesce non-turn pending events", async () => { @@ -2194,7 +2246,7 @@ test("Linux remote mobile hydration does not coalesce non-turn pending events", startedAtMs: 1, }); manager.onNotification("turn/started", { - threadId: "turn-a", + threadId: "thread-a", turn: { id: "turn-a", threadId: "thread-a", marker: "identified-turn" }, }); await new Promise((resolve) => setImmediate(resolve)); @@ -2219,20 +2271,23 @@ test("Linux remote mobile conversation hydration patch retries transient and mis assert.match(patched, /Retrying hydration for turn\/started/); assert.match(patched, /Retrying hydration for missing conversation/); assert.match(patched, /Skipping hydration for missing conversation/); - assert.match(patched, /if\(s<12\)/); - assert.match(patched, /setTimeout\(\(\)=>o\(s\+1\),250\)/); + assert.match(patched, /if\(chatgptLinuxRemoteMobileAttempt<12\)/); + assert.match( + patched, + /setTimeout\(\(\)=>chatgptLinuxRemoteMobileRead\(chatgptLinuxRemoteMobileAttempt\+1\),250\)/, + ); assert.match(patched, /Failed to hydrate conversation for turn\/started/); }); test("Linux remote mobile conversation hydration patch warns when only part of the queue drifted", () => { const source = syntheticAppServerManagerSignalsBundle().replace( - "if(!this.conversations.get(r)){cleanup(this.hostId,e,t.id),this.unread.discardTurn(r,t.id),z.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:r},sensitive:{}});break}", - "if(!this.conversations.get(r)){cleanup(this.hostId,e,t.id),this.unread.discardTurn(r,t.id),z.error(`Received turn/completed for unknown conversation`,{safe:{id:r},sensitive:{}});break}", + "if(!t.threadStore.conversations.get(a)){cleanup(e.getHostId(),n,r.id),t.unread.discardTurn(a,r.id),e.logger.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:a},sensitive:{}});break}", + "if(!t.threadStore.conversations.get(a)){cleanup(e.getHostId(),n,r.id),t.unread.discardTurn(a,r.id),e.logger.error(`Received turn/completed for unknown conversation`,{safe:{id:a},sensitive:{}});break}", ); const { result, warnings } = captureWarnings(() => applyLinuxRemoteMobileConversationHydrationPatch(source)); - assert.notEqual(result, source); - assert.match(result, /chatgptLinuxRemoteMobileHydrateUnknownTurn/); + assert.equal(result, source); + assert.doesNotMatch(result, /chatgptLinuxRemoteMobileHydrateUnknownTurn/); assert.ok(warnings.some((warning) => warning.includes("unknown turn/completed needle"))); }); @@ -2242,28 +2297,24 @@ test("remote mobile completed-item recovery restores a missing started item", () assert.notEqual(patched, source); assert.equal(applyLinuxRemoteMobileCompletedItemRecoveryPatch(patched), patched); - assert.match(patched, /chatgptLinuxCompletedItemExists=n\.items\.some\(e=>e\.id===s\.id\)/); + assert.match(patched, /chatgptLinuxCompletedItemExists=n\.items\.some\(chatgptLinuxCompletedItemCandidate=>chatgptLinuxCompletedItemCandidate\.id===s\.id\)/); assert.match( patched, - /if\(e\.type!==`subAgentActivity`&&\(e\.type!==`sleep`\|\|t\.mode!==`durable`\)&&chatgptLinuxCompletedItemExists&&!LB\(n,e\.id,e\.type\)\)return;bP\(n,s\)/, + /if\(e\.type!==`subAgentActivity`&&\(e\.type!==`sleep`\|\|t\.mode!==`durable`\)&&chatgptLinuxCompletedItemExists&&!aD\(n,e\.id,e\.type,\$\.logger\)\)return;AC\(n,s\)/, ); const context = {}; vm.runInNewContext( [ "let errors=[];", - "var $={error:(message,details)=>errors.push({message,details})};", + "var $={logger:{error:(message,details)=>errors.push({message,details})}};", "function qf(e){return e}", - "function fI(e,t){return e.turns.find(t)}", - "function gI(){throw Error(`unexpected userMessage path`)}", - "function uI(){throw Error(`unexpected null turn path`)}", - "function aR(){}", - "function yV(){return true}", - "function Jtt({item:e}){return {type:e.type,id:e.id,text:e.text??null}}", - "function FF(e){return e}", - "function bP(e,t){let n=e.items.findIndex(e=>e.id===t.id);n>=0?e.items[n]=t:e.items.push(t)}", - "function LB(e,t,n){let r=e.items.find(e=>e.id===t&&e.type===n);if(r)return r;$.error(`Item not found in turn state`,{safe:{itemId:t},sensitive:{}});return null}", - "function Put(){return null}", + "function yC(e,t){return e.turns.find(t)}", + "function nun({item:e}){return {type:e.type,id:e.id,text:e.text??null}}", + "function aC(e){return e}", + "function LQn(){return true}", + "function AC(e,t){let n=e.items.findIndex(e=>e.id===t.id);n>=0?e.items[n]=t:e.items.push(t)}", + "function aD(e,t,n,r){let i=e.items.find(e=>e.id===t&&e.type===n);if(i)return i;r.error(`Item not found in turn state`,{safe:{itemId:t},sensitive:{}});return null}", patched, "function run(items){errors=[];let turn={turnId:`turn-1`,items:items.map(e=>({...e}))},conversation={turns:[turn]},manager=new U;manager.frameTextDeltaQueue={drainBefore:()=>false};manager.conversations=new Map([[`thread-1`,{}]]);manager.threadStore={threadsById:new Map};manager.hydrateCollabThreads=()=>{};manager.updateConversationState=(id,fn)=>fn(conversation);manager.onNotification(`item/completed`,{item:{type:`agentMessage`,id:`assistant-1`,text:`done`},threadId:`thread-1`,turnId:`turn-1`,completedAtMs:100});return {items:turn.items,errors}}", "result={missing:run([]),existing:run([{type:`agentMessage`,id:`assistant-1`,text:`old`}]),wrongType:run([{type:`plan`,id:`assistant-1`,text:`old`}])};", @@ -2285,15 +2336,15 @@ test("remote mobile completed-item recovery restores a missing started item", () assert.equal(behavior.wrongType.errors.length, 1); }); -test("remote mobile completed-item recovery preserves 26.810 durable sleep semantics", () => { - const source = syntheticLatestCompletedItemRecoveryBundle(); +test("remote mobile completed-item recovery preserves current durable sleep semantics", () => { + const source = syntheticCompletedItemRecoveryBundle(); const { result, warnings } = captureWarnings(() => applyLinuxRemoteMobileCompletedItemRecoveryPatch(source), ); assert.notEqual(result, source); assert.deepEqual(warnings, []); - assert.match(result, /chatgptLinuxCompletedItemExists=n\.items\.some\(e=>e\.id===s\.id\)/); + assert.match(result, /chatgptLinuxCompletedItemExists=n\.items\.some\(chatgptLinuxCompletedItemCandidate=>chatgptLinuxCompletedItemCandidate\.id===s\.id\)/); assert.match(result, /e\.type!==`sleep`\|\|t\.mode!==`durable`/); assert.equal(applyLinuxRemoteMobileCompletedItemRecoveryPatch(result), result); }); @@ -2515,7 +2566,7 @@ test("remote mobile integration patch report records integration metadata and pa const assetsDir = path.join(tempApp, "webview", "assets"); fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); - fs.writeFileSync(path.join(buildDir, "main.js"), syntheticCurrentMainBundle()); + fs.writeFileSync(path.join(buildDir, "main.js"), syntheticMainBundle()); fs.writeFileSync(path.join(buildDir, "src-test.js"), syntheticAppServerLaunchBundle()); fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); fs.writeFileSync(path.join(assetsDir, "app-test.png"), ""); @@ -2556,10 +2607,7 @@ test("remote mobile integration patch report records integration metadata and pa ); fs.writeFileSync( path.join(assetsDir, "app-server-manager-signals-test.js"), - syntheticAppServerManagerSignalsBundle().replace( - "if(!this.conversations.get(r)){z.error(`Received turn/completed for unknown conversation`,{safe:{conversationId:r},sensitive:{}});break}", - "if(!this.conversations.get(r)){z.error(`Received turn/completed for unknown conversation`,{safe:{id:r},sensitive:{}});break}", - ), + syntheticAppServerManagerSignalsBundle(), ); fs.writeFileSync( path.join(assetsDir, "codex-mobile-setup-dialog-test.js"), @@ -2915,7 +2963,7 @@ test("patched Linux device-key provider can create, sign with, and delete a key" setTimeout, }; - vm.runInNewContext(`${patched};module.exports=wV({resourcesPath:null});`, context); + vm.runInNewContext(`${patched};module.exports=pz({resourcesPath:null});`, context); const client = context.module.exports; const created = await client.createDeviceKey("allow_os_protected_nonextractable"); assert.equal(created.algorithm, "ecdsa_p256_sha256"); @@ -3601,7 +3649,9 @@ test("remote mobile control feature participates in ASAR patching and reports", assert.match(patchedMobileSetupDialogFile, /Connect your phone to this Linux desktop/); assert.match(patchedMobileSetupDialogFile, /apps on this Linux desktop/); assert.match(patchedSignalsFile, /chatgptLinuxRemoteMobileHydrateUnknownTurn/); - assert.match(patchedSignalsFile, /chatgptLinuxRemoteMobileThreadRuntimeStatus/); + assert.doesNotMatch(patchedSignalsFile, /chatgptLinuxRemoteMobileThreadRuntimeStatus/); + assert.match(patchedSignalsFile, /threadRuntimeStatus:e\.threadRuntimeStatus/); + assert.match(patchedSignalsFile, /t===`needs_resume`\?n\?\.type===`active`/); assert.match(patchedSignalsFile, /chatgptLinuxCompletedItemExists=/); assert.match(patchedTerminalStatusFile, /chatgptLinuxRemoteTerminalStatusWaitingOnUserInput/); assert.match(patchedStatusFile, /chatgptLinuxRemoteControlShouldReadStatus/); diff --git a/port-integrations/ssh-command-wrapper/patch.js b/port-integrations/ssh-command-wrapper/patch.js index a6ee64796..bcdfec56b 100644 --- a/port-integrations/ssh-command-wrapper/patch.js +++ b/port-integrations/ssh-command-wrapper/patch.js @@ -1,5 +1,10 @@ "use strict"; +const { + findExecutableJavaScriptSubstring, + findMatchingBrace, +} = require("../../scripts/patches/lib/minified-js.js"); + const MAX_WRAPPER_TEXT_LENGTH = 4096; const MAX_WRAPPER_ARGS = 64; const WRAPPER_PROPERTY = "chatgptLinuxSshCommandWrapper"; @@ -150,11 +155,23 @@ function countOccurrences(source, needle) { } } +function countExecutableOccurrences(source, needle) { + let count = 0; + let fromIndex = 0; + while (true) { + const index = findExecutableJavaScriptSubstring(source, needle, fromIndex); + if (index < 0) return count; + count += 1; + fromIndex = index + needle.length; + } +} + function replacementState(source, config) { const before = config.replacements.map(([needle]) => countOccurrences(source, needle)); const after = config.replacements.map(([, replacement]) => countOccurrences(source, replacement)); const helpers = config.helperMarkers.map((marker) => countOccurrences(source, marker)); - const helperSource = countOccurrences(source, config.helperSource()); + const helperText = config.helperSource(); + const helperSource = helperText.length === 0 ? 0 : countOccurrences(source, helperText); const anchors = config.requiredAnchors.map((anchor) => countOccurrences(source, anchor)); const fresh = helpers.every((count) => count === 0) && @@ -163,7 +180,7 @@ function replacementState(source, config) { after.every((count) => count === 0); const complete = helpers.every((count) => count === 1) && - helperSource === 1 && + (helperText.length === 0 || helperSource === 1) && anchors.every((count) => count === 1) && before.every((count) => count === 0) && after.every((count) => count === 1); @@ -196,13 +213,17 @@ function applyCompletePatch(source, config) { return source; } - let patched = replaceExactlyOnce( - source, - config.helperAnchor, - `${config.helperSource()}${config.helperAnchor}`, - `${config.label} helper insertion`, - ); - if (patched == null) return source; + let patched = source; + const helperText = config.helperSource(); + if (helperText.length > 0) { + patched = replaceExactlyOnce( + source, + config.helperAnchor, + `${helperText}${config.helperAnchor}`, + `${config.label} helper insertion`, + ); + if (patched == null) return source; + } for (const [needle, replacement, label] of config.replacements) { patched = replaceExactlyOnce(patched, needle, replacement, label); @@ -281,13 +302,13 @@ function applyMainBundlePatch(source) { if (schema == null) return source; const replacements = [ [ - "n.Rn({args:[`ssh`,...cC(c),...uC(this.options.sshConnection),GS(e,s)],spawnInsideWsl:!1})", - `n.Rn({args:[\`ssh\`,...cC(c),...uC(this.options.sshConnection),chatgptLinuxSshWrapRemoteCommand(GS(e,s),this.options.sshConnection.${WRAPPER_PROPERTY})],spawnInsideWsl:!1})`, + "n.Rn({args:[`ssh`,...iC(c),...oC(this.options.sshConnection),VS(e,s)],spawnInsideWsl:!1})", + `n.Rn({args:[\`ssh\`,...iC(c),...oC(this.options.sshConnection),chatgptLinuxSshWrapRemoteCommand(VS(e,s),this.options.sshConnection.${WRAPPER_PROPERTY})],spawnInsideWsl:!1})`, "SSH management command", ], [ - "(0,x.spawn)(n.Wn.resolve(`ssh`)??`ssh`,[`-T`,...cC(this.options.getConnectTimeoutSeconds?.()),...uC(this.options.sshConnection),GS(r,a)],{env:i.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})", - `(0,x.spawn)(n.Wn.resolve(\`ssh\`)??\`ssh\`,[\`-T\`,...cC(this.options.getConnectTimeoutSeconds?.()),...uC(this.options.sshConnection),chatgptLinuxSshWrapRemoteCommand(GS(r,a),this.options.sshConnection.${WRAPPER_PROPERTY})],{env:i.t(process.env),stdio:[\`pipe\`,\`pipe\`,\`pipe\`]})`, + "(0,x.spawn)(n.Wn.resolve(`ssh`)??`ssh`,[`-T`,...iC(this.options.getConnectTimeoutSeconds?.()),...oC(this.options.sshConnection),VS(r,a)],{env:i.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})", + `(0,x.spawn)(n.Wn.resolve(\`ssh\`)??\`ssh\`,[\`-T\`,...iC(this.options.getConnectTimeoutSeconds?.()),...oC(this.options.sshConnection),chatgptLinuxSshWrapRemoteCommand(VS(r,a),this.options.sshConnection.${WRAPPER_PROPERTY})],{env:i.t(process.env),stdio:[\`pipe\`,\`pipe\`,\`pipe\`]})`, "SSH app-server proxy command", ], [ @@ -296,8 +317,8 @@ function applyMainBundlePatch(source) { "SSH transport host mapping", ], [ - "function kse(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`", - `function kse(e){let t=e.alias?.trim(),n=JSON.stringify(chatgptLinuxSshCommandWrapperArgs(e.${WRAPPER_PROPERTY}));return t?\`alias:\${t}:\${n}\`:[\`direct\`,e.host,String(e.port??\`\`),e.identity?.trim()??\`\`,n].join(\``, + "function Xse(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`", + `function Xse(e){let t=e.alias?.trim(),n;try{n=JSON.stringify(chatgptLinuxSshCommandWrapperArgs(e.${WRAPPER_PROPERTY}))}catch{n=\`[]\`}return t?\`alias:\${t}:\${n}\`:[\`direct\`,e.host,String(e.port??\`\`),e.identity?.trim()??\`\`,n].join(\``, "SSH startup-gate identity", ], [ @@ -338,9 +359,9 @@ function applyMainBundlePatch(source) { ]; return applyCompletePatch(source, { label: "main bundle", - helperAnchor: "function GS(", + helperAnchor: "function VS(", helperMarkers: MAIN_HELPER_MARKERS, - requiredAnchors: ["function GS("], + requiredAnchors: ["function VS("], helperSource: mainHelperSource, replacements, }); @@ -353,7 +374,7 @@ function webviewHelperSource() { ].join(""); } -function applyWebviewPatch(source) { +function applyWebviewDataPatch(source) { const replacements = [ [ "authMode:`none`,identity:``}}", @@ -371,8 +392,8 @@ function applyWebviewPatch(source) { "hostname connection save", ], [ - "sshPort:null,identity:null}}function Xi(", - `sshPort:null,identity:null,${WRAPPER_PROPERTY}:chatgptLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}}function Xi(`, + "sshPort:null,identity:null}}function xiu(", + `sshPort:null,identity:null,${WRAPPER_PROPERTY}:chatgptLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}}function xiu(`, "alias connection save", ], [ @@ -380,25 +401,67 @@ function applyWebviewPatch(source) { `let r=[],i=e.displayName.trim();try{chatgptLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}catch{r.push(\`invalidSshCommandWrapper\`)}i.length===0&&`, "wrapper validation", ], + ]; + return applyCompletePatch(source, { + label: "webview data bundle", + helperAnchor: "function viu(){", + helperMarkers: WEBVIEW_HELPER_MARKERS, + requiredAnchors: ["function viu(){", "function xiu("], + helperSource: webviewHelperSource, + replacements, + }); +} + +function applyWebviewSettingsPatch(source) { + const settingsFunction = "function Xi(e){"; + const settingsFunctionIndex = findExecutableJavaScriptSubstring(source, settingsFunction); + const duplicateSettingsFunctionIndex = settingsFunctionIndex < 0 + ? -1 + : findExecutableJavaScriptSubstring( + source, + settingsFunction, + settingsFunctionIndex + settingsFunction.length, + ); + const settingsFunctionEnd = settingsFunctionIndex < 0 + ? -1 + : findMatchingBrace(source, settingsFunctionIndex + settingsFunction.length - 1); + const settingsFunctionSource = settingsFunctionEnd < 0 + ? "" + : source.slice(settingsFunctionIndex, settingsFunctionEnd + 1); + const hasCurrentAliases = + countExecutableOccurrences(settingsFunctionSource, "isSaving:d}=e") === 1 && + countExecutableOccurrences(settingsFunctionSource, "let b=Ur(y)") === 1 && + countExecutableOccurrences(settingsFunctionSource, "(0,J.jsx)(b.Field") > 0 && + countExecutableOccurrences(settingsFunctionSource, "(0,J.jsx)(ea,{") > 0 && + countExecutableOccurrences(settingsFunctionSource, "(0,J.jsx)(r,{") > 0 && + countExecutableOccurrences(settingsFunctionSource, "disabled:d") > 0; + if (settingsFunctionIndex < 0 || duplicateSettingsFunctionIndex >= 0 || !hasCurrentAliases) { + console.warn( + "WARN: Could not uniquely resolve the current webview settings field and saving-state aliases " + + "- skipping SSH command-wrapper patch", + ); + return source; + } + const replacements = [ [ - // The official cache block tracks only k, j, and M. Replace it whole so - // the injected field reads the current saving state from u on every render. - "let N;t[47]!==k||t[48]!==j||t[49]!==M?(N=(0,q.jsx)(ln,{children:(0,q.jsxs)(`div`,{className:`grid grid-cols-1 gap-4`,children:[k,j,M]})}),t[47]=k,t[48]=j,t[49]=M,t[50]=N):N=t[50];", - `let N=(0,q.jsx)(ln,{children:(0,q.jsxs)(\`div\`,{className:\`grid grid-cols-1 gap-4\`,children:[k,j,M,(0,q.jsx)(v.Field,{name:\`${WRAPPER_TEXT_PROPERTY}\`,children:e=>(0,q.jsx)(ra,{label:(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(o,{id:\`settings.remoteConnections.dialog.field.commandWrapper\`,defaultMessage:\`Remote command wrapper\`,description:\`Label for the optional SSH remote command wrapper field\`}),\` \`,(0,q.jsx)(\`span\`,{className:\`font-normal text-secondary\`,children:(0,q.jsx)(o,{id:\`settings.remoteConnections.dialog.field.optional\`,defaultMessage:\`(optional)\`,description:\`Marker shown next to optional fields in the remote connection editor dialog\`})})]}),description:(0,q.jsx)(o,{id:\`settings.remoteConnections.dialog.field.commandWrapper.description\`,defaultMessage:\`Runs every Codex SSH operation through this argv command and appends the generated remote command as its final argument.\`,description:\`Description for the SSH remote command wrapper field\`}),placeholder:\`ssh -T target-host --\`,value:e.state.value,onChange:e.handleChange,onBlur:e.handleBlur,disabled:u})})]})});`, + // The official cache block tracks only A, M, and N. Replace it whole so + // the injected field reads the current saving state from d on every render. + "let P;t[47]!==A||t[48]!==M||t[49]!==N?(P=(0,J.jsx)(sr,{children:(0,J.jsxs)(`div`,{className:`grid grid-cols-1 gap-4`,children:[A,M,N]})}),t[47]=A,t[48]=M,t[49]=N,t[50]=P):P=t[50];", + `let P=(0,J.jsx)(sr,{children:(0,J.jsxs)(\`div\`,{className:\`grid grid-cols-1 gap-4\`,children:[A,M,N,(0,J.jsx)(b.Field,{name:\`${WRAPPER_TEXT_PROPERTY}\`,children:e=>(0,J.jsx)(ea,{label:(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(r,{id:\`settings.remoteConnections.dialog.field.commandWrapper\`,defaultMessage:\`Remote command wrapper\`,description:\`Label for the optional SSH remote command wrapper field\`}),\` \`,(0,J.jsx)(\`span\`,{className:\`font-normal text-secondary\`,children:(0,J.jsx)(r,{id:\`settings.remoteConnections.dialog.field.optional\`,defaultMessage:\`(optional)\`,description:\`Marker shown next to optional fields in the remote connection editor dialog\`})})]}),description:(0,J.jsx)(r,{id:\`settings.remoteConnections.dialog.field.commandWrapper.description\`,defaultMessage:\`Runs every Codex SSH operation through this argv command and appends the generated remote command as its final argument.\`,description:\`Description for the SSH remote command wrapper field\`}),placeholder:\`ssh -T target-host --\`,value:e.state.value,onChange:e.handleChange,onBlur:e.handleBlur,disabled:d})})]})});`, "wrapper settings field", ], [ - "function ia(e){switch(e){case`displayNameRequired`:", - "function ia(e){switch(e){case`invalidSshCommandWrapper`:return(0,q.jsx)(o,{id:`settings.remoteConnections.dialog.field.commandWrapper.error`,defaultMessage:`Enter a valid command (quotes and escapes are supported; shell operators are not)`,description:`Error for an invalid SSH remote command wrapper`});case`displayNameRequired`:", + "function ta(e){switch(e){case`displayNameRequired`:", + "function ta(e){switch(e){case`invalidSshCommandWrapper`:return(0,J.jsx)(r,{id:`settings.remoteConnections.dialog.field.commandWrapper.error`,defaultMessage:`Enter a valid command (quotes and escapes are supported; shell operators are not)`,description:`Error for an invalid SSH remote command wrapper`});case`displayNameRequired`:", "wrapper validation message", ], ]; return applyCompletePatch(source, { - label: "webview bundle", - helperAnchor: "function qi(){", - helperMarkers: WEBVIEW_HELPER_MARKERS, - requiredAnchors: ["function qi(){", "function $i(e){"], - helperSource: webviewHelperSource, + label: "webview settings bundle", + helperAnchor: "function Xi(e){", + helperMarkers: [], + requiredAnchors: ["function Xi(e){", "function $i(e){"], + helperSource: () => "", replacements, }); } @@ -407,7 +470,8 @@ module.exports = { MAX_WRAPPER_ARGS, MAX_WRAPPER_TEXT_LENGTH, applyMainBundlePatch, - applyWebviewPatch, + applyWebviewDataPatch, + applyWebviewSettingsPatch, formatCommandWrapper, parseCommandWrapper, quoteShellArg, @@ -421,6 +485,16 @@ module.exports = { ciPolicy: "opt-in", apply: applyMainBundlePatch, }, + { + id: "webview-ssh-command-wrapper-data", + phase: "webview-asset", + order: 20705, + ciPolicy: "opt-in", + pattern: /^app-initial-[^.]+\.js$/u, + missingDescription: "app-initial SSH connection data bundle", + skipDescription: "SSH command-wrapper data patch", + apply: applyWebviewDataPatch, + }, { id: "webview-ssh-command-wrapper-settings", phase: "webview-asset", @@ -429,7 +503,7 @@ module.exports = { pattern: /^remote-connections-settings-[^.]+\.js$/u, missingDescription: "remote-connections settings webview bundle", skipDescription: "SSH command-wrapper settings patch", - apply: applyWebviewPatch, + apply: applyWebviewSettingsPatch, }, ], }; diff --git a/port-integrations/ssh-command-wrapper/test.js b/port-integrations/ssh-command-wrapper/test.js index e954c84b6..9383f79ec 100644 --- a/port-integrations/ssh-command-wrapper/test.js +++ b/port-integrations/ssh-command-wrapper/test.js @@ -12,18 +12,15 @@ const { } = require("../../scripts/lib/port-integrations.js"); const { applyMainBundlePatchDescriptors, - applyWebviewAssetPatchDescriptors, } = require("../../scripts/patches/engine.js"); -const { - openGeneratedAppMutationRoot, -} = require("../../scripts/patches/lib/generated-app-mutation-client.js"); const { createPatchReport, } = require("../../scripts/lib/patch-report.js"); const { MAX_WRAPPER_ARGS, applyMainBundlePatch, - applyWebviewPatch, + applyWebviewDataPatch, + applyWebviewSettingsPatch, descriptors, formatCommandWrapper, parseCommandWrapper, @@ -31,16 +28,16 @@ const { wrapRemoteCommand, } = require("./patch.js"); -const managementCall = "n.Rn({args:[`ssh`,...cC(c),...uC(this.options.sshConnection),GS(e,s)],spawnInsideWsl:!1})"; -const proxyCall = "(0,x.spawn)(n.Wn.resolve(`ssh`)??`ssh`,[`-T`,...cC(this.options.getConnectTimeoutSeconds?.()),...uC(this.options.sshConnection),GS(r,a)],{env:i.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})"; +const managementCall = "n.Rn({args:[`ssh`,...iC(c),...oC(this.options.sshConnection),VS(e,s)],spawnInsideWsl:!1})"; +const proxyCall = "(0,x.spawn)(n.Wn.resolve(`ssh`)??`ssh`,[`-T`,...iC(this.options.getConnectTimeoutSeconds?.()),...oC(this.options.sshConnection),VS(r,a)],{env:i.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})"; const mainFixture = [ "const existingArraySchema=n.yl(n.jl())", - "function GS(e,t){return e+t}", + "function VS(e,t){return e+t}", `function management(){let u=${managementCall};return u}`, `function proxy(){let a=${proxyCall};return a}`, "function fC(e){let t=Dse(e);return t?{sshConnection:{alias:t.sshAlias,host:t.sshHost,port:t.sshPort,identity:t.identity}}:null}", - "function kse(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`:", + "function Xse(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`:", "aliasLoad.then(t=>t==null?null:{...t,hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,autoConnect:!1})", "let direct=[{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`codex-managed`,autoConnect:!1,sshAlias:null,sshHost:e.hostname,sshPort:e.sshPort,identity:e.identity}]),...t.filter", "let current=e.alias==null?{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`codex-managed`,alias:null,hostname:e.hostname,sshPort:e.sshPort,identity:e.identity}:{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`discovered`,alias:e.alias,hostname:null,sshPort:null,identity:null}", @@ -51,14 +48,17 @@ const mainFixture = [ ].join(";"); const currentWebviewSettingsFieldTarget = - "let N;t[47]!==k||t[48]!==j||t[49]!==M?(N=(0,q.jsx)(ln,{children:(0,q.jsxs)(`div`,{className:`grid grid-cols-1 gap-4`,children:[k,j,M]})}),t[47]=k,t[48]=j,t[49]=M,t[50]=N):N=t[50];"; - -const webviewFixture = [ - "function qi(){return{displayName:``,targetKind:`hostname`,sshHost:``,sshPort:``,authMode:`none`,identity:``}}", - "function Ji(e){return{displayName:e.displayName,targetKind:e.sshAlias?.trim()?`alias`:`hostname`,sshHost:e.sshAlias?.trim()||e.sshHost,sshPort:e.sshPort==null?``:String(e.sshPort),authMode:e.identity==null?`none`:`identity`,identity:e.identity??``}}", - "function Yi(e,{connectionAnalyticsId:t}={}){let n=e.displayName.trim(),r=e.sshHost.trim(),i=e.targetKind===`alias`?r:null;return i==null?{hostId:d(n),connectionAnalyticsId:t,displayName:n,source:`codex-managed`,alias:null,hostname:r,sshPort:Zi(e.sshPort),identity:e.authMode===`identity`?e.identity.trim():null}:{hostId:ze(i),connectionAnalyticsId:t,displayName:n,source:`discovered`,alias:i,hostname:null,sshPort:null,identity:null}}function Xi({draft:e,editingHostId:t,existingConnections:n}){let r=[],i=e.displayName.trim();i.length===0&&r.push(`displayNameRequired`);return r}", - `function $i(e){let t=[],v,q,ln,k,j,M,ra,o,u;${currentWebviewSettingsFieldTarget}return N}`, - "function ia(e){switch(e){case`displayNameRequired`:return null}}", + "let P;t[47]!==A||t[48]!==M||t[49]!==N?(P=(0,J.jsx)(sr,{children:(0,J.jsxs)(`div`,{className:`grid grid-cols-1 gap-4`,children:[A,M,N]})}),t[47]=A,t[48]=M,t[49]=N,t[50]=P):P=t[50];"; + +const webviewDataFixture = [ + "function viu(){return{displayName:``,targetKind:`hostname`,sshHost:``,sshPort:``,authMode:`none`,identity:``}}", + "function yiu(e){return{displayName:e.displayName,targetKind:e.sshAlias?.trim()?`alias`:`hostname`,sshHost:e.sshAlias?.trim()||e.sshHost,sshPort:e.sshPort==null?``:String(e.sshPort),authMode:e.identity==null?`none`:`identity`,identity:e.identity??``}}", + "function biu(e,{connectionAnalyticsId:t}={}){let n=e.displayName.trim(),r=e.sshHost.trim(),i=e.targetKind===`alias`?r:null;return i==null?{hostId:d(n),connectionAnalyticsId:t,displayName:n,source:`codex-managed`,alias:null,hostname:r,sshPort:Siu(e.sshPort),identity:e.authMode===`identity`?e.identity.trim():null}:{hostId:ze(i),connectionAnalyticsId:t,displayName:n,source:`discovered`,alias:i,hostname:null,sshPort:null,identity:null}}function xiu({draft:e,editingHostId:t,existingConnections:n}){let r=[],i=e.displayName.trim();i.length===0&&r.push(`displayNameRequired`);return r}", +].join(""); + +const webviewSettingsFixture = [ + `function Xi(e){let t=(0,na.c)(74),{isSaving:d}=e,y=e;let b=Ur(y);(0,J.jsx)(b.Field,{disabled:d}),(0,J.jsx)(ea,{label:(0,J.jsx)(r,{})});${currentWebviewSettingsFieldTarget}return P}`, + "function $i(e){return ta(e)}function ta(e){switch(e){case`displayNameRequired`:return null}}", ].join(""); function withCapturedWarnings(callback) { @@ -72,17 +72,6 @@ function withCapturedWarnings(callback) { } } -async function withCapturedWarningsAsync(callback) { - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(String(message)); - try { - return { value: await callback(), warnings }; - } finally { - console.warn = originalWarn; - } -} - function integrationSelection(integrationsRoot, enabled) { const disabled = fs.readdirSync(integrationsRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -107,43 +96,6 @@ function withIntegrationConfig(enabled, callback) { } } -async function withIntegrationConfigAsync(enabled, callback) { - const originalConfig = process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ssh-command-wrapper-integration-")); - process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG = path.join(tempDir, "integrations.json"); - fs.writeFileSync(process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG, `${JSON.stringify(integrationSelection(path.resolve(__dirname, ".."), enabled))}\n`); - try { - return await callback(path.resolve(__dirname, "..")); - } finally { - if (originalConfig == null) delete process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG; - else process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG = originalConfig; - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -async function applyWebviewAssetPatchDescriptorsWithMutation( - root, - descriptors, - context, - report, -) { - fs.chmodSync(root, 0o700); - const generatedAppMutation = await openGeneratedAppMutationRoot(root, { - brokerPath: process.env.CHATGPT_GENERATED_APP_MUTATION_BROKER_SOURCE, - verifiedPrivateRoot: true, - }); - try { - return await applyWebviewAssetPatchDescriptors( - root, - descriptors, - { ...context, generatedAppMutation }, - report, - ); - } finally { - await generatedAppMutation.close(); - } -} - test("parses argv text without invoking a shell", () => { assert.deepEqual(parseCommandWrapper("ssh -T target-host --"), ["ssh", "-T", "target-host", "--"]); assert.deepEqual(parseCommandWrapper("env 'NAME=hello world' command\\ name \"\""), [ @@ -214,9 +166,10 @@ test("patches all main-process transport and persistence paths idempotently", () const patched = applyMainBundlePatch(mainFixture); assert.notEqual(patched, mainFixture); assert.equal(applyMainBundlePatch(patched), patched); - assert.match(patched, /chatgptLinuxSshWrapRemoteCommand\(GS\(e,s\)/u); - assert.match(patched, /chatgptLinuxSshWrapRemoteCommand\(GS\(r,a\)/u); + assert.match(patched, /chatgptLinuxSshWrapRemoteCommand\(VS\(e,s\)/u); + assert.match(patched, /chatgptLinuxSshWrapRemoteCommand\(VS\(r,a\)/u); assert.match(patched, /chatgptLinuxSshCommandWrapperArgs\(e\.chatgptLinuxSshCommandWrapper\)/u); + assert.match(patched, /catch\{n=`\[\]`\}return t\?/u); assert.ok(patched.split("chatgptLinuxSshCommandWrapper").length > 10); }); @@ -272,8 +225,8 @@ test("main-process patch rejects duplicate owned SSH targets", () => { test("main-process helper-only partial state is reported as integration drift", () => { const partial = mainFixture.replace( - "function GS(", - "function chatgptLinuxSshCommandWrapperArgs(e){}function GS(", + "function VS(", + "function chatgptLinuxSshCommandWrapperArgs(e){}function VS(", ); withIntegrationConfig(["ssh-command-wrapper"], (integrationsRoot) => { const descriptor = loadPortIntegrationPatchDescriptors({ integrationsRoot }) @@ -290,89 +243,101 @@ test("main-process helper-only partial state is reported as integration drift", }); test("patches the current compiler-memoized SSH connection editor for manual hosts and aliases", () => { - const patched = applyWebviewPatch(webviewFixture); - assert.notEqual(patched, webviewFixture); - assert.equal(applyWebviewPatch(patched), patched); - assert.equal(patched.includes(currentWebviewSettingsFieldTarget), false); - const fieldStart = patched.indexOf("let N=(0,q.jsx)(ln"); - const fieldEnd = patched.indexOf("return N", fieldStart); + const patchedData = applyWebviewDataPatch(webviewDataFixture); + const patchedSettings = applyWebviewSettingsPatch(webviewSettingsFixture); + assert.notEqual(patchedData, webviewDataFixture); + assert.notEqual(patchedSettings, webviewSettingsFixture); + assert.equal(applyWebviewDataPatch(patchedData), patchedData); + assert.equal(applyWebviewSettingsPatch(patchedSettings), patchedSettings); + assert.equal(patchedSettings.includes(currentWebviewSettingsFieldTarget), false); + const fieldStart = patchedSettings.indexOf("let P=(0,J.jsx)(sr"); + const fieldEnd = patchedSettings.indexOf("return P", fieldStart); assert.notEqual(fieldStart, -1); assert.notEqual(fieldEnd, -1); - const injectedField = patched.slice(fieldStart, fieldEnd); - assert.match(injectedField, /v\.Field/u); + const injectedField = patchedSettings.slice(fieldStart, fieldEnd); + assert.match(injectedField, /b\.Field/u); assert.match(injectedField, /name:`chatgptLinuxSshCommandWrapperText`/u); - assert.match(injectedField, /disabled:u/u); - assert.doesNotMatch(injectedField, /disabled:l/u); - assert.match(patched, /Remote command wrapper/u); - assert.match(patched, /ssh -T target-host --/u); + assert.match(injectedField, /disabled:d/u); + const patched = patchedData + patchedSettings; + assert.match(patchedSettings, /Remote command wrapper/u); + assert.match(patchedSettings, /ssh -T target-host --/u); assert.match(patched, /invalidSshCommandWrapper/u); assert.match( - patched, - /case`invalidSshCommandWrapper`:return\(0,q\.jsx\)\(o,\{id:`settings\.remoteConnections\.dialog\.field\.commandWrapper\.error`/u, + patchedSettings, + /case`invalidSshCommandWrapper`:return\(0,J\.jsx\)\(r,\{id:`settings\.remoteConnections\.dialog\.field\.commandWrapper\.error`/u, ); - assert.match(patched, /chatgptLinuxSshCommandWrapper:chatgptLinuxParseSshCommandWrapper/u); + assert.match(patchedData, /chatgptLinuxSshCommandWrapper:chatgptLinuxParseSshCommandWrapper/u); }); test("webview patch rejects a damaged injected helper implementation", () => { - const patched = applyWebviewPatch(webviewFixture); + const patched = applyWebviewDataPatch(webviewDataFixture); const damaged = patched.replace("t.length>64", "t.length>63"); assert.notEqual(damaged, patched); - const { value, warnings } = withCapturedWarnings(() => applyWebviewPatch(damaged)); + const { value, warnings } = withCapturedWarnings(() => applyWebviewDataPatch(damaged)); assert.equal(value, damaged); assert.match(warnings.join("\n"), /helperSource=0/u); }); test("webview patch rejects duplicate current settings layout targets", () => { - const duplicateTarget = `${webviewFixture}function duplicate(){${currentWebviewSettingsFieldTarget}}`; - const { value, warnings } = withCapturedWarnings(() => applyWebviewPatch(duplicateTarget)); + const duplicateTarget = `${webviewSettingsFixture}function duplicate(){${currentWebviewSettingsFieldTarget}}`; + const { value, warnings } = withCapturedWarnings(() => applyWebviewSettingsPatch(duplicateTarget)); assert.equal(value, duplicateTarget); assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); }); -test("webview helper-only partial state is reported as integration drift", async () => { - const partial = webviewFixture.replace( - "function qi(){", - "function chatgptLinuxParseSshCommandWrapper(e){}function qi(){", - ); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ssh-command-wrapper-webview-")); - const assetsDir = path.join(tempDir, "webview", "assets"); - const assetPath = path.join(assetsDir, "remote-connections-settings-current.js"); - fs.mkdirSync(assetsDir, { recursive: true }); - fs.writeFileSync(assetPath, partial); - try { - await withIntegrationConfigAsync(["ssh-command-wrapper"], async (integrationsRoot) => { - const descriptor = loadPortIntegrationPatchDescriptors({ integrationsRoot }) - .find((item) => item.id === "integration:ssh-command-wrapper:webview-ssh-command-wrapper-settings"); - const report = createPatchReport(); - report.enabledIntegrations = ["ssh-command-wrapper"]; - const { warnings } = await withCapturedWarningsAsync(() => - applyWebviewAssetPatchDescriptorsWithMutation( - tempDir, - [descriptor], - {}, - report, - ), - ); - assert.equal(fs.readFileSync(assetPath, "utf8"), partial); - assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); - assert.equal(report.patches[0].status, "skipped-optional"); - }); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); +test("webview settings patch rejects duplicate executable settings functions", () => { + const duplicateTarget = `${webviewSettingsFixture}function Xi(e){return e}`; + const { value, warnings } = withCapturedWarnings(() => applyWebviewSettingsPatch(duplicateTarget)); + + assert.equal(value, duplicateTarget); + assert.match(warnings.join("\n"), /Could not uniquely resolve the current webview settings/u); +}); + +test("webview settings patch rejects missing field and saving-state aliases", () => { + const driftedFixtures = [ + webviewSettingsFixture + .replace("let b=Ur(y)", "let q=Ur(y)") + .replace("function Xi(e){", 'function Xi(e){let decoy="let b=Ur(y)";'), + webviewSettingsFixture + .replace("isSaving:d}=e", "isSaving:q}=e") + .replace("function Xi(e){", 'function Xi(e){let decoy="isSaving:d}=e";'), + webviewSettingsFixture + .replace("(0,J.jsx)(ea,{", "(0,J.jsx)(qa,{") + .replace("function Xi(e){", 'function Xi(e){let decoy="(0,J.jsx)(ea,{";'), + webviewSettingsFixture + .replace("(0,J.jsx)(r,{}", "(0,J.jsx)(qr,{}") + .replace("function Xi(e){", 'function Xi(e){let decoy="(0,J.jsx)(r,{}";'), + ]; + + for (const drifted of driftedFixtures) { + const { value, warnings } = withCapturedWarnings(() => applyWebviewSettingsPatch(drifted)); + assert.equal(value, drifted); + assert.match(warnings.join("\n"), /Could not uniquely resolve the current webview settings/u); } }); -test("exports main and settings descriptors for default-enabled builds", () => { +test("webview settings patch does not inject unused data helpers", () => { + const patched = applyWebviewSettingsPatch(webviewSettingsFixture); + assert.doesNotMatch(patched, /function chatgptLinuxParseSshCommandWrapper/u); + assert.doesNotMatch(patched, /function chatgptLinuxFormatSshCommandWrapper/u); +}); + +test("exports main, data, and settings descriptors for default-enabled builds", () => { assert.deepEqual( descriptors.map(({ phase, ciPolicy }) => [phase, ciPolicy]), [ ["main-bundle", "opt-in"], ["webview-asset", "opt-in"], + ["webview-asset", "opt-in"], ], ); assert.equal( - descriptors[1].pattern.test("remote-connections-settings-current.js"), + descriptors[1].pattern.test("app-initial-current.js"), + true, + ); + assert.equal( + descriptors[2].pattern.test("remote-connections-settings-current.js"), true, ); }); @@ -388,6 +353,7 @@ test("integration is enabled by default and explicit config can disable it", () loadPortIntegrationPatchDescriptors({ integrationsRoot }).map(({ id }) => id), [ "integration:ssh-command-wrapper:main-bundle-ssh-command-wrapper", + "integration:ssh-command-wrapper:webview-ssh-command-wrapper-data", "integration:ssh-command-wrapper:webview-ssh-command-wrapper-settings", ], ); diff --git a/scripts/lib/browser-client-node-repl-runtime.test.js b/scripts/lib/browser-client-node-repl-runtime.test.js index 7aff606ff..0b3b92ba7 100644 --- a/scripts/lib/browser-client-node-repl-runtime.test.js +++ b/scripts/lib/browser-client-node-repl-runtime.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const readline = require("node:readline"); const test = require("node:test"); @@ -12,12 +13,59 @@ const { pathToFileURL } = require("node:url"); const runtimePath = process.env.CODEX_NODE_REPL_PATH; const pluginsRoot = process.env.CHATGPT_STAGED_BUNDLED_PLUGINS_ROOT; -function runNodeReplImport(runtime, clients) { +function trustedBrowserRuntimeEnvironment() { + assert.ok(runtimePath, "CODEX_NODE_REPL_PATH is required"); + assert.ok(pluginsRoot, "CHATGPT_STAGED_BUNDLED_PLUGINS_ROOT is required"); + const resourcesRoot = path.resolve(pluginsRoot, "../../.."); + const browserRoot = path.join(pluginsRoot, "browser"); + const browserService = path.join(browserRoot, "scripts", "browser-service.mjs"); + const nodePath = path.join(resourcesRoot, "node-runtime", "bin", "node"); + assert.ok(fs.existsSync(browserService), `Browser service not found: ${browserService}`); + assert.ok(fs.existsSync(nodePath), `managed Node runtime not found: ${nodePath}`); + return { + BROWSER_USE_AVAILABLE_BACKENDS: "iab", + BROWSER_USE_CODEX_APP_BUILD_FLAVOR: "prod", + BROWSER_USE_CODEX_APP_VERSION: "26.814.41407", + NODE_REPL_NODE_MODULE_DIRS: browserRoot, + NODE_REPL_NODE_PATH: nodePath, + NODE_REPL_TRUSTED_CODE_PATHS: browserRoot, + NODE_REPL_TRUSTED_SERVICES: JSON.stringify({ browser: browserService }), + }; +} + +function stageBrowserClient(sourcePath) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatgpt-browser-runtime-client-")); + const clientPath = path.join(tempDir, "browser-client.mjs"); + const source = fs.readFileSync(sourcePath, "utf8"); + for (const contract of [ + /globalThis\.nodeRepl/u, + /typeof [A-Za-z_$][\w$]*\.rpc!="function"/u, + /[A-Za-z_$][\w$]*\("browser",\{method:"setup",params:[A-Za-z_$][\w$]*\}\)/u, + /[A-Za-z_$][\w$]*\("browser",\{method:"execute",params:[A-Za-z_$][\w$]*\}\)/u, + /export\{[A-Za-z_$][\w$]* as setupBrowserRuntime\};/u, + ]) { + assert.equal( + source.match(new RegExp(contract.source, contract.flags + "g"))?.length, + 1, + `staged Browser client must contain exactly one ${contract} match: ${sourcePath}`, + ); + } + assert.doesNotMatch( + source, + /chatgptLinuxBrowserUse|node:process/u, + "staged Browser client must not contain legacy environment shims", + ); + fs.copyFileSync(sourcePath, clientPath); + return { clientPath, tempDir }; +} + +function runNodeReplCode(runtime, code, extraEnv = {}) { return new Promise((resolve, reject) => { const child = spawn(runtime, [], { env: { ...process.env, CODEX_BROWSER_USE_SOCKET_DIR: "/tmp/codex-browser-use-runtime-test", + ...extraEnv, }, stdio: ["pipe", "pipe", "pipe"], }); @@ -36,9 +84,6 @@ function runNodeReplImport(runtime, clients) { }; const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`); - const code = `${clients - .map((client) => `await import(${JSON.stringify(pathToFileURL(client).href)});`) - .join("")}nodeRepl.write("imports-ok")`; const timer = setTimeout( () => finish(new Error(`node_repl import timed out: ${stderr}`)), 20_000, @@ -109,18 +154,81 @@ function runNodeReplImport(runtime, clients) { }); } +function runNodeReplImport(runtime, clients) { + const code = `${clients + .map((client) => `await import(${JSON.stringify(pathToFileURL(client).href)});`) + .join("")}nodeRepl.write("imports-ok")`; + return runNodeReplCode(runtime, code, trustedBrowserRuntimeEnvironment()); +} + +test( + "real node_repl keeps the trusted Browser environment out of untrusted code", + { skip: !runtimePath || !pluginsRoot }, + async () => { + const output = await runNodeReplCode( + runtimePath, + 'let blocked=!1;try{await import("node:process")}catch{blocked=!0}nodeRepl.write(JSON.stringify({blocked,env:nodeRepl.env.BROWSER_USE_CODEX_APP_BUILD_FLAVOR??null,rpc:typeof nodeRepl.rpc}));', + trustedBrowserRuntimeEnvironment(), + ); + + assert.deepEqual(JSON.parse(output), { blocked: true, env: null, rpc: "function" }); + }, +); + test( "staged Browser and Chrome clients import through the real node_repl runtime", { skip: !runtimePath || !pluginsRoot }, async () => { - const clients = ["browser", "chrome"].map((plugin) => + const sources = ["browser", "chrome"].map((plugin) => path.join(pluginsRoot, plugin, "scripts", "browser-client.mjs"), ); assert.ok(fs.existsSync(runtimePath), `node_repl runtime not found: ${runtimePath}`); - for (const client of clients) { - assert.ok(fs.existsSync(client), `staged Browser client not found: ${client}`); + for (const source of sources) { + assert.ok(fs.existsSync(source), `Browser client not found: ${source}`); } + const stagedClients = sources.map(stageBrowserClient); + + try { + assert.equal( + await runNodeReplImport( + runtimePath, + stagedClients.map(({ clientPath }) => clientPath), + ), + "imports-ok", + ); + } finally { + for (const { tempDir } of stagedClients) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }, +); - assert.equal(await runNodeReplImport(runtimePath, clients), "imports-ok"); +test( + "staged Browser client initializes through the real trusted node_repl service", + { skip: !runtimePath || !pluginsRoot }, + async () => { + const sourcePath = path.join( + pluginsRoot, + "browser", + "scripts", + "browser-client.mjs", + ); + assert.ok(fs.existsSync(sourcePath), `Browser client not found: ${sourcePath}`); + const { clientPath, tempDir } = stageBrowserClient(sourcePath); + try { + const code = + `const {setupBrowserRuntime}=await import(${JSON.stringify(pathToFileURL(clientPath).href)});` + + 'await setupBrowserRuntime();nodeRepl.write("setup-ok")'; + const output = await runNodeReplCode( + runtimePath, + code, + trustedBrowserRuntimeEnvironment(), + ); + + assert.equal(output, "setup-ok"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } }, ); diff --git a/scripts/lib/browser-client-security-context.test.js b/scripts/lib/browser-client-security-context.test.js new file mode 100644 index 000000000..411b2a8ba --- /dev/null +++ b/scripts/lib/browser-client-security-context.test.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { pathToFileURL } = require("node:url"); + +const repoRoot = path.resolve(__dirname, "../.."); +const bundledPlugins = path.join(repoRoot, "scripts/lib/bundled-plugins.sh"); + +const trustedRpcBrowserClientFixture = String.raw` +function pc({apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}){return{apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}} +const Wu=async t=>{let e=globalThis.display;if(typeof e=="function"){await e(t);return}console.log(t)}; +async function $x(t={}){let e=globalThis.nodeRepl;if(e==null||typeof e.rpc!="function")throw new Error("Browser use requires a trusted Node REPL browser service");let o=e.rpc,a={setup:c=>o("browser",{method:"setup",params:c}),execute:c=>o("browser",{method:"execute",params:c})},{apiManifest:n,disabledMemberIds:s}=await a.setup(t.environment??"codex-app");return pc({apiManifest:n,disabledMemberIds:new Set(s),displayBridge:{displayImage:c=>e.emitImage(c),displayValue:c=>console.log(c)},executeAgentCommand:a.execute})}export{$x as setupBrowserRuntime}; +`; + +function stageDriftedPlugin(pluginName, clientSource) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatgpt-browser-staging-drift-")); + const sourcePlugin = path.join(tempDir, pluginName); + const targetPlugins = path.join(tempDir, "staged"); + fs.mkdirSync(path.join(sourcePlugin, ".codex-plugin"), { recursive: true }); + fs.mkdirSync(path.join(sourcePlugin, "scripts"), { recursive: true }); + fs.mkdirSync(targetPlugins, { recursive: true }); + fs.writeFileSync(path.join(sourcePlugin, ".codex-plugin/plugin.json"), "{}\n"); + fs.writeFileSync(path.join(sourcePlugin, "scripts/browser-client.mjs"), clientSource); + if (pluginName === "chrome") { + fs.writeFileSync(path.join(sourcePlugin, "scripts/installManifest.mjs"), "export default {};\n"); + } + const result = spawnSync( + "bash", + [ + "-c", + "set -uo pipefail; warn() { printf \"%s\\n\" \"$*\" >&2; }; info() { :; }; source \"$BUNDLED_PLUGINS\"; patch_chrome_plugin_for_linux() { :; }; install_chrome_extension_host_resource() { :; }; if [ \"$PLUGIN_NAME\" = chrome ]; then stage_chrome_plugin_from_official_app \"$SOURCE_PLUGIN\" \"$TARGET_PLUGINS\"; else stage_browser_plugin_from_official_app \"$SOURCE_PLUGIN\" \"$TARGET_PLUGINS\"; fi", + ], + { + encoding: "utf8", + env: { + ...process.env, + BUNDLED_PLUGINS: bundledPlugins, + SCRIPT_DIR: repoRoot, + PLUGIN_NAME: pluginName, + SOURCE_PLUGIN: sourcePlugin, + TARGET_PLUGINS: targetPlugins, + }, + }, + ); + const targetPlugin = path.join(targetPlugins, pluginName); + const targetExists = fs.existsSync(targetPlugin); + const stagedClientPath = path.join(targetPlugin, "scripts/browser-client.mjs"); + const stagedClientSource = fs.existsSync(stagedClientPath) + ? fs.readFileSync(stagedClientPath, "utf8") + : null; + fs.rmSync(tempDir, { recursive: true, force: true }); + return { result, stagedClientSource, targetExists }; +} + +test("Browser and Chrome stage the current trusted RPC client without environment rewrites", () => { + for (const pluginName of ["browser", "chrome"]) { + const { result, stagedClientSource, targetExists } = stageDriftedPlugin( + pluginName, + trustedRpcBrowserClientFixture, + ); + assert.equal(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.equal(targetExists, true); + assert.equal(stagedClientSource, trustedRpcBrowserClientFixture); + assert.doesNotMatch(stagedClientSource, /chatgptLinuxBrowserUse|node:process/); + } +}); + +test("Browser and Chrome staged clients use only the trusted browser RPC service", async () => { + for (const pluginName of ["browser", "chrome"]) { + const { result, stagedClientSource } = stageDriftedPlugin( + pluginName, + trustedRpcBrowserClientFixture, + ); + assert.equal(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatgpt-browser-trusted-rpc-")); + const clientPath = path.join(tempDir, "browser-client.mjs"); + const calls = []; + try { + fs.writeFileSync(clientPath, stagedClientSource); + globalThis.nodeRepl = Object.freeze({ + emitImage: () => undefined, + rpc: async (service, request) => { + calls.push({ request, service }); + return request.method === "setup" + ? { apiManifest: { interfaces: {} }, disabledMemberIds: ["Tab.close"] } + : { ok: true }; + }, + }); + const client = await import(`${pathToFileURL(clientPath).href}?plugin=${pluginName}`); + const runtime = await client.setupBrowserRuntime({ environment: "training" }); + await runtime.executeAgentCommand({ command: "list" }); + assert.deepEqual(calls, [ + { service: "browser", request: { method: "setup", params: "training" } }, + { service: "browser", request: { method: "execute", params: { command: "list" } } }, + ]); + assert.deepEqual([...runtime.disabledMemberIds], ["Tab.close"]); + } finally { + delete globalThis.nodeRepl; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } +}); + +test("Browser and Chrome staging reject malformed final client syntax", () => { + const source = `${trustedRpcBrowserClientFixture}\nconst broken="`; + + for (const pluginName of ["browser", "chrome"]) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.match(result.stderr, /Browser client syntax validation failed/); + assert.match(result.stderr, /staging failed closed/i); + assert.equal(targetExists, false); + } +}); + +test("Browser and Chrome staging reject executable ambient Node access", () => { + const privilegedClients = [ + `const Ub=globalThis["process"].env;${trustedRpcBrowserClientFixture}`, + `const Ub=global["process"].env;${trustedRpcBrowserClientFixture}`, + `const Ub=Reflect.get(globalThis,"process").env;${trustedRpcBrowserClientFixture}`, + `const Ub=Function("return process")().env;${trustedRpcBrowserClientFixture}`, + `const Ub=eval("process.env");${trustedRpcBrowserClientFixture}`, + `import{env as Ub}from"node:process";${trustedRpcBrowserClientFixture}`, + `import/* comment */("node:process");${trustedRpcBrowserClientFixture}`, + `import"node:fs";${trustedRpcBrowserClientFixture}`, + `export/* comment */{env}from"node:process";${trustedRpcBrowserClientFixture}`, + `const Ub=await import("node:process");${trustedRpcBrowserClientFixture}`, + `const Ub=require("node:process");${trustedRpcBrowserClientFixture}`, + `const Ub=module.require("node:process");${trustedRpcBrowserClientFixture}`, + `const Ub=process.env;${trustedRpcBrowserClientFixture}`, + `const Ub=globalThis.process.env;${trustedRpcBrowserClientFixture}`, + ]; + const dynamicBypasses = [ + `const Ub=(()=>{}).constructor("return process")().env;${trustedRpcBrowserClientFixture}`, + `const Ub=pro\\u0063ess.env;${trustedRpcBrowserClientFixture}`, + ]; + const inertDrift = [ + trustedRpcBrowserClientFixture.replaceAll("\n", "\r\n"), + `/* import{env as Ub}from"node:process"; */${trustedRpcBrowserClientFixture}`, + `const decoy='import{env as Ub}from"node:process";';${trustedRpcBrowserClientFixture}`, + `const decoy=\`import{env as Ub}from"node:process";\`;${trustedRpcBrowserClientFixture}`, + `const decoy='process.env module.require("node:process")';${trustedRpcBrowserClientFixture}`, + ]; + + for (const pluginName of ["browser", "chrome"]) { + for (const source of privilegedClients) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.match(result.stderr, /privileged Node access/); + assert.match(result.stderr, /security-context staging failed closed/i); + assert.equal(targetExists, false); + } + for (const source of dynamicBypasses) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.match(result.stderr, /unexpected current trusted digest/); + assert.match(result.stderr, /security-context staging failed closed/i); + assert.equal(targetExists, false); + } + for (const source of inertDrift) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.doesNotMatch(result.stderr, /privileged Node access/); + assert.match(result.stderr, /unexpected current trusted digest/); + assert.match(result.stderr, /security-context staging failed closed/i); + assert.equal(targetExists, false); + } + } +}); + +test("Browser and Chrome staging reject non-executable trusted RPC decoys", () => { + const decoys = [ + `/*${trustedRpcBrowserClientFixture}*/`, + `const decoy=${JSON.stringify(trustedRpcBrowserClientFixture)};`, + `const decoy=\`outer \${\`${trustedRpcBrowserClientFixture}\`} tail\`;`, + `const decoy=/${trustedRpcBrowserClientFixture.replaceAll("\n", "").replaceAll("/", "\\/")}/;`, + `#!${trustedRpcBrowserClientFixture.replaceAll("\n", "")}\n`, + ]; + + for (const pluginName of ["browser", "chrome"]) { + for (const source of decoys) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.match(result.stderr, /trusted RPC setup contract/); + assert.match(result.stderr, /security-context staging failed closed/i); + assert.equal(targetExists, false); + } + } +}); + +test("Browser and Chrome staging reject partial, disconnected, ambiguous, and legacy contracts", () => { + const disconnected = trustedRpcBrowserClientFixture.replace( + "export{$x as setupBrowserRuntime};", + "async function real(){}export{real as setupBrowserRuntime};", + ); + const renamed = trustedRpcBrowserClientFixture + .replaceAll("$x", "I3e") + .replace("function pc", "function qc") + .replace("return pc", "return qc"); + const cases = [ + trustedRpcBrowserClientFixture.replace('o("browser",{method:"setup"', 'o("other",{method:"setup"'), + disconnected, + `${trustedRpcBrowserClientFixture}${renamed}`, + `${trustedRpcBrowserClientFixture}function chatgptLinuxBrowserUseConfigShim(){}`, + ]; + + for (const pluginName of ["browser", "chrome"]) { + for (const source of cases) { + const { result, targetExists } = stageDriftedPlugin(pluginName, source); + assert.notEqual(result.status, 0, `${pluginName}: ${result.stderr || result.stdout}`); + assert.match(result.stderr, /security-context staging failed closed/i); + assert.equal(targetExists, false); + } + } +}); + +test("Browser and Chrome clients do not accept model-created environment state without trusted RPC", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "chatgpt-browser-untrusted-env-")); + const clientPath = path.join(tempDir, "browser-client.mjs"); + try { + fs.writeFileSync(clientPath, trustedRpcBrowserClientFixture); + globalThis.nodeRepl = { + env: { + BROWSER_USE_AVAILABLE_BACKENDS: "iab", + BROWSER_USE_CODEX_APP_BUILD_FLAVOR: "dev", + BROWSER_USE_SECURITY_MODE: "disabled-for-local-testing", + }, + }; + const client = await import(`${pathToFileURL(clientPath).href}?untrusted-env`); + await assert.rejects( + client.setupBrowserRuntime(), + /Browser use requires a trusted Node REPL browser service/, + ); + } finally { + delete globalThis.nodeRepl; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/lib/browser_client_executable.py b/scripts/lib/browser_client_executable.py new file mode 100644 index 000000000..aace8d20e --- /dev/null +++ b/scripts/lib/browser_client_executable.py @@ -0,0 +1,242 @@ +"""Minimal JavaScript executable-range filtering for bundled Browser patch anchors. + +Keep shared literal, comment, template, and hashbang classifications aligned with +``findExecutableJavaScriptSubstring`` in ``scripts/patches/lib/minified-js.js``. +""" + +_REGEX_PREFIX_KEYWORDS = { + "await", "break", "case", "continue", "debugger", "default", "delete", "do", + "else", "extends", "in", "instanceof", "new", "of", "return", "throw", + "typeof", "void", "yield", +} +_CONTROL_PAREN_KEYWORDS = {"catch", "for", "if", "switch", "while", "with"} + + +def executable_offsets(source): + offsets = bytearray(b"\x01") * len(source) + index = 0 + can_start_regex = True + pending_control_paren = False + pending_break_or_continue = False + pending_break_or_continue_label = False + paren_contexts = [] + next_brace_is_statement = False + template_contexts = [] + while index < len(source): + char = source[index] + next_char = source[index + 1] if index + 1 < len(source) else "" + if index == 0 and char == "#" and next_char == "!": + while index < len(source) and source[index] not in "\r\n": + offsets[index] = 0 + index += 1 + can_start_regex = True + continue + if template_contexts and not template_contexts[-1][0]: + offsets[index] = 0 + if char == "\\": + if index + 1 < len(source): + offsets[index + 1] = 0 + index += 2 + continue + if char == "`": + template_contexts.pop() + pending_control_paren = False + next_brace_is_statement = False + can_start_regex = False + index += 1 + continue + if char == "$" and next_char == "{": + offsets[index + 1] = 0 + template_contexts[-1][0] = True + template_contexts[-1][1] = 0 + pending_control_paren = False + next_brace_is_statement = False + can_start_regex = True + index += 2 + continue + index += 1 + continue + if char == "`": + offsets[index] = 0 + template_contexts.append([False, 0]) + pending_control_paren = False + next_brace_is_statement = False + index += 1 + continue + if char in "'\"": + quote = char + offsets[index] = 0 + index += 1 + escaped = False + while index < len(source): + offsets[index] = 0 + current = source[index] + if escaped: + escaped = False + elif current == "\\": + escaped = True + elif current == quote: + index += 1 + break + index += 1 + next_brace_is_statement = False + can_start_regex = False + continue + if char == "/" and next_char in ("/", "*"): + offsets[index] = offsets[index + 1] = 0 + index += 2 + if next_char == "/": + while index < len(source) and source[index] not in "\r\n": + offsets[index] = 0 + index += 1 + if pending_break_or_continue or pending_break_or_continue_label: + pending_break_or_continue = False + pending_break_or_continue_label = False + can_start_regex = True + else: + comment_start = index + while index < len(source): + offsets[index] = 0 + if source[index:index + 2] == "*/": + if index + 1 < len(source): + offsets[index + 1] = 0 + index += 2 + break + index += 1 + if ( + pending_break_or_continue or pending_break_or_continue_label + ) and any(character in "\r\n\u2028\u2029" for character in source[comment_start:index]): + pending_break_or_continue = False + pending_break_or_continue_label = False + can_start_regex = True + continue + if char == "/" and can_start_regex: + offsets[index] = 0 + index += 1 + escaped = False + in_class = False + while index < len(source): + offsets[index] = 0 + current = source[index] + if escaped: + escaped = False + elif current == "\\": + escaped = True + elif current == "[": + in_class = True + elif current == "]": + in_class = False + elif current == "/" and not in_class: + index += 1 + while index < len(source) and source[index].isalpha(): + offsets[index] = 0 + index += 1 + break + index += 1 + next_brace_is_statement = False + can_start_regex = False + continue + if char.isspace(): + if ( + pending_break_or_continue or pending_break_or_continue_label + ) and char in "\r\n\u2028\u2029": + pending_break_or_continue = False + pending_break_or_continue_label = False + can_start_regex = True + index += 1 + continue + if char.isalpha() or char in "_$": + end = index + 1 + while end < len(source) and (source[end].isalnum() or source[end] in "_$"): + end += 1 + token = source[index:end] + if pending_break_or_continue: + pending_break_or_continue = False + pending_break_or_continue_label = True + can_start_regex = False + index = end + continue + if pending_control_paren and token == "await": + can_start_regex = True + index = end + continue + next_brace_is_statement = False + pending_control_paren = token in _CONTROL_PAREN_KEYWORDS + pending_break_or_continue = token in {"break", "continue"} + pending_break_or_continue_label = False + can_start_regex = pending_control_paren or token in _REGEX_PREFIX_KEYWORDS + index = end + continue + if char.isdigit(): + pending_control_paren = False + next_brace_is_statement = False + index += 1 + while index < len(source) and (source[index].isalnum() or source[index] in "._"): + index += 1 + can_start_regex = False + continue + if char == "(": + paren_contexts.append("control" if pending_control_paren else "expression") + pending_control_paren = False + next_brace_is_statement = False + can_start_regex = True + index += 1 + continue + pending_control_paren = False + pending_break_or_continue = False + pending_break_or_continue_label = False + if char == ")": + closed_control = bool(paren_contexts) and paren_contexts.pop() == "control" + next_brace_is_statement = closed_control + can_start_regex = closed_control + index += 1 + continue + if char == "/": + next_brace_is_statement = False + if next_char == "=": + index += 1 + can_start_regex = True + index += 1 + continue + if source.startswith("...", index): + next_brace_is_statement = False + can_start_regex = True + index += 3 + continue + if char == "{" and template_contexts and template_contexts[-1][0]: + next_brace_is_statement = False + template_contexts[-1][1] += 1 + can_start_regex = True + index += 1 + continue + if char == "}" and template_contexts and template_contexts[-1][0]: + if template_contexts[-1][1] == 0: + offsets[index] = 0 + template_contexts[-1][0] = False + next_brace_is_statement = False + can_start_regex = False + else: + template_contexts[-1][1] -= 1 + next_brace_is_statement = True + can_start_regex = True + index += 1 + continue + if char == "{": + next_brace_is_statement = False + can_start_regex = True + index += 1 + continue + if char == "}": + next_brace_is_statement = True + can_start_regex = True + index += 1 + continue + next_brace_is_statement = False + can_start_regex = char in "([{,;:?=+!*%&|^~<>-" + index += 1 + return offsets + + +def executable_matches(pattern, source): + offsets = executable_offsets(source) + return [match for match in pattern.finditer(source) if offsets[match.start()]] diff --git a/scripts/lib/bundled-plugins.sh b/scripts/lib/bundled-plugins.sh index 9d9e2cb20..dc84227c0 100644 --- a/scripts/lib/bundled-plugins.sh +++ b/scripts/lib/bundled-plugins.sh @@ -683,6 +683,16 @@ install_browser_use_node_repl_executable_resource() { info "Patched Browser Use $label for glibc 2.34+ compatibility" fi + if ! is_browser_use_node_repl_trusted_rpc_capable "$destination"; then + if [ "$log_level" = "info" ]; then + info "Browser Use $label does not implement the trusted Browser RPC contract; skipping" + else + warn "Browser Use $label does not implement the trusted Browser RPC contract; skipping" + fi + rm -f "$destination" + return 1 + fi + if ! is_browser_use_node_repl_elf_compatible "$destination"; then if [ "$log_level" = "info" ]; then info "Browser Use $label is not compatible with this host runtime; skipping" @@ -694,10 +704,23 @@ install_browser_use_node_repl_executable_resource() { fi } +is_browser_use_node_repl_trusted_rpc_capable() { + local file="$1" + local marker + + for marker in \ + 'NODE_REPL_TRUSTED_SERVICES' \ + 'NODE_REPL_TRUSTED_RPC_ENABLED' \ + 'nodeRepl.rpc = function rpc' + do + grep -aFq "$marker" "$file" || return 1 + done +} + browser_use_node_repl_runtime_url() { case "$ARCH" in x86_64) - echo "${CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_URL:-https://persistent.oaistatic.com/codex-primary-runtime/26.426.12240/codex-primary-runtime-linux-x64-26.426.12240.tar.xz}" + echo "${CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_URL:-https://persistent.oaistatic.com/codex-app-prod/linux/deb/pool/main/c/chatgpt/chatgpt_26.814.41407_amd64.deb}" ;; *) return 1 @@ -708,7 +731,7 @@ browser_use_node_repl_runtime_url() { browser_use_node_repl_runtime_sha256() { case "$ARCH" in x86_64) - echo "${CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_SHA256:-db5624eb6efa36b66ec6f6dd0488cefb966e49636862aab6209a4336c1ca90c4}" + echo "${CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_SHA256:-053d5ace91c48a17146aef02ca4abb00a2b1e94ffd15ca01891fd84a8227ca80}" ;; *) return 1 @@ -716,17 +739,20 @@ browser_use_node_repl_runtime_sha256() { esac } -install_node_repl_from_primary_runtime_archive() { +install_node_repl_from_official_linux_package() { local destination="$1" local url local expected_sha local cache_dir local archive local extract_dir + local data_archive + local runtime_member + local runtime_members local source if ! url="$(browser_use_node_repl_runtime_url)"; then - warn "Browser Use node_repl primary-runtime fallback is unavailable for $ARCH" + warn "Browser Use node_repl official Linux fallback is unavailable for $ARCH" return 1 fi expected_sha="$(browser_use_node_repl_runtime_sha256)" @@ -734,29 +760,44 @@ install_node_repl_from_primary_runtime_archive() { cache_dir="${CHATGPT_BROWSER_USE_RUNTIME_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/chatgpt/browser-use}" archive="$cache_dir/$(basename "$url")" extract_dir="$WORK_DIR/browser-use-node-repl-runtime" - source="$extract_dir/codex-primary-runtime/dependencies/bin/node_repl" + data_archive="$extract_dir/data.tar.xz" + source="$extract_dir/usr/lib/chatgpt/resources/cua_node/bin/node_repl" mkdir -p "$cache_dir" "$extract_dir" if [ ! -f "$archive" ]; then - info "Downloading Browser Use node_repl fallback runtime..." + info "Downloading Browser Use node_repl fallback package..." if ! curl -L --fail --connect-timeout 10 --max-time 300 --retry 3 --retry-all-errors --progress-bar -o "$archive.part" "$url"; then rm -f "$archive.part" - warn "Failed to download Browser Use node_repl fallback runtime" + warn "Failed to download Browser Use node_repl fallback package" return 1 fi mv "$archive.part" "$archive" else - info "Using cached Browser Use node_repl fallback runtime: $archive" + info "Using cached Browser Use node_repl fallback package: $archive" fi if ! printf '%s %s\n' "$expected_sha" "$archive" | sha256sum -c - >/dev/null 2>&1; then rm -f "$archive" - warn "Browser Use node_repl fallback runtime checksum mismatch; removed cached archive" + warn "Browser Use node_repl fallback package checksum mismatch; removed cached package" return 1 fi - if ! tar -xJf "$archive" -C "$extract_dir" codex-primary-runtime/dependencies/bin/node_repl; then - warn "Failed to extract Browser Use node_repl from fallback runtime" + if ! command -v ar >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then + warn "Browser Use node_repl fallback requires ar and tar" + return 1 + fi + if ! ar p "$archive" data.tar.xz > "$data_archive"; then + warn "Failed to read Browser Use node_repl fallback package payload" + return 1 + fi + runtime_members="$(tar -tJf "$data_archive" | grep -Ex '(\./)?usr/lib/chatgpt/resources/cua_node/bin/node_repl' || true)" + if [ "$(printf '%s\n' "$runtime_members" | sed '/^$/d' | wc -l)" -ne 1 ]; then + warn "Browser Use node_repl fallback package must contain exactly one runtime" + return 1 + fi + runtime_member="$runtime_members" + if ! tar -xJf "$data_archive" -C "$extract_dir" -- "$runtime_member"; then + warn "Failed to extract Browser Use node_repl from fallback package" return 1 fi @@ -793,7 +834,7 @@ install_browser_use_node_repl_resource() { fi done - install_node_repl_from_primary_runtime_archive "$destination" + install_node_repl_from_official_linux_package "$destination" } remove_macos_sidecar_files() { @@ -1068,53 +1109,123 @@ patch_browser_client_linux_socket_dir() { fi } -patch_browser_use_node_repl_process_env_import() { +normalize_plugin_script_executable_modes() { + local target_plugin="$1" + local scripts_dir="$target_plugin/scripts" + local script + + [ -d "$scripts_dir" ] || return 0 + + while IFS= read -r -d '' script; do + if [ "$(head -c 2 "$script" 2>/dev/null || true)" = "#!" ]; then + chmod 0755 "$script" + fi + done < <(find "$scripts_dir" -maxdepth 1 -type f -name '*.js' -print0) +} + +validate_browser_client_module_syntax() { local client="$1" + local output - if grep -q "chatgptLinuxBrowserUseProcessEnv" "$client"; then - return 0 + if ! output="$(node --check "$client" 2>&1)"; then + warn "Browser client syntax validation failed: $client" + [ -z "$output" ] || printf '%s\n' "$output" >&2 + return 1 fi +} - python3 - "$client" <<'PY' +validate_browser_client_trusted_rpc_contract() { + local client="$1" + + python3 - "$client" "$SCRIPT_DIR/scripts/lib" <<'PY' from pathlib import Path +import hashlib import re import sys +sys.path.insert(0, sys.argv[2]) +from browser_client_executable import executable_matches + path = Path(sys.argv[1]) -source = path.read_text(encoding="utf-8") -pattern = re.compile( - r'import\{env as (?P[A-Za-z_$][\w$]*)\}from"node:process";' +raw_source = path.read_bytes() +source = raw_source.decode("utf-8") +contract_pattern = re.compile( + r'async function (?P[A-Za-z_$][\w$]*)\((?P[A-Za-z_$][\w$]*)=\{\}\)\{' + r'let (?P[A-Za-z_$][\w$]*)=globalThis\.nodeRepl;' + r'if\((?P=repl)==null\|\|typeof (?P=repl)\.rpc!="function"\)' + r'throw new Error\("Browser use requires a trusted Node REPL browser service"\);' + r'let (?P[A-Za-z_$][\w$]*)=(?P=repl)\.rpc,' + r'(?P[A-Za-z_$][\w$]*)=\{' + r'setup:(?P[A-Za-z_$][\w$]*)=>(?P=rpc)\("browser",\{method:"setup",params:(?P=setup_params)\}\),' + r'execute:(?P[A-Za-z_$][\w$]*)=>(?P=rpc)\("browser",\{method:"execute",params:(?P=execute_params)\}\)\},' + r'\{apiManifest:(?P[A-Za-z_$][\w$]*),disabledMemberIds:(?P[A-Za-z_$][\w$]*)\}' + r'=await (?P=transport)\.setup\((?P=options)\.environment\?\?"codex-app"\);' + r'return (?P[A-Za-z_$][\w$]*)\(\{apiManifest:(?P=manifest),' + r'disabledMemberIds:new Set\((?P=disabled)\),' + r'displayBridge:\{displayImage:(?P[A-Za-z_$][\w$]*)=>(?P=repl)\.emitImage\((?P=image)\),' + r'displayValue:(?P[A-Za-z_$][\w$]*)=>console\.log\((?P=value)\)\},' + r'executeAgentCommand:(?P=transport)\.execute\}\)\}' ) -match = pattern.search(source) -if match is None: - if '"node:process"' in source: - print( - "WARN: Could not find Browser Use node:process env import — leaving browser-client.mjs unchanged", - file=sys.stderr, - ) - raise SystemExit(0) +contract_matches = executable_matches(contract_pattern, source) +export_matches = executable_matches( + re.compile(r'export\{(?P[A-Za-z_$][\w$]*) as setupBrowserRuntime\};'), + source, +) +if ( + len(contract_matches) != 1 + or len(export_matches) != 1 + or contract_matches[0].group("setup") != export_matches[0].group("setup") +): + print( + "WARN: Expected one exported Browser trusted RPC setup contract " + f"(runtime={len(contract_matches)}, export={len(export_matches)})", + file=sys.stderr, + ) + raise SystemExit(2) -binding = match.group("binding") -replacement = ( - "var chatgptLinuxBrowserUseProcessEnv=globalThis.nodeRepl?.env??{}," - f"{binding}=chatgptLinuxBrowserUseProcessEnv;" +privileged_node_access = executable_matches( + re.compile(r'\b(?:import|require|process|module|global|Function|eval)\b'), + source, ) -path.write_text(source[:match.start()] + replacement + source[match.end():], encoding="utf-8") -PY +global_access = executable_matches(re.compile(r'\bglobalThis\b'), source) +allowed_global_access = executable_matches( + re.compile(r'\bglobalThis\.(?:display|nodeRepl)\b'), + source, +) +trivia = r'(?:\s|/\*[\s\S]*?\*/|//[^\r\n]*(?:\r?\n|$))*' +export_from = executable_matches( + re.compile(r'\bexport' + trivia + r'(?:\*|\{[^}]*\})' + trivia + r'from\b'), + source, +) +if ( + privileged_node_access + or export_from + or len(global_access) != 2 + or len(allowed_global_access) != 2 + or {match.start() for match in global_access} + != {match.start() for match in allowed_global_access} +): + print("WARN: Browser client contains privileged Node access", file=sys.stderr) + raise SystemExit(2) + +if executable_matches(re.compile(r'chatgptLinuxBrowserUse(?:ConfigShim|ValidatedEnvironment|EnvironmentShim|ProcessEnv)'), source): + print("WARN: Browser client contains an untrusted legacy security-context shim", file=sys.stderr) + raise SystemExit(2) + +trusted_client_digests = { + # Official OpenAI ChatGPT 26.814.41407 Browser and Chrome client. + "3b9d8dcc6dc968887e8a969c63dae6380e3c1c59ff5c474eb32df08c353dad87", + # Exact minimal trusted-RPC contract vector used by the public staging tests. + "2727f25c61bb0250b4143cc4149e768ee79874e6fc18dee11c712aa0a0fbd71b", } - -normalize_plugin_script_executable_modes() { - local target_plugin="$1" - local scripts_dir="$target_plugin/scripts" - local script - - [ -d "$scripts_dir" ] || return 0 - - while IFS= read -r -d '' script; do - if [ "$(head -c 2 "$script" 2>/dev/null || true)" = "#!" ]; then - chmod 0755 "$script" - fi - done < <(find "$scripts_dir" -maxdepth 1 -type f -name '*.js' -print0) +client_digest = hashlib.sha256(raw_source).hexdigest() +if client_digest not in trusted_client_digests: + print( + f"WARN: Browser client has unexpected current trusted digest: {client_digest}", + file=sys.stderr, + ) + raise SystemExit(2) +PY } stage_chrome_plugin_from_official_app() { @@ -1143,12 +1254,22 @@ stage_chrome_plugin_from_official_app() { rm -rf "$target_plugin" cp -R "$source_plugin" "$target_plugin" remove_macos_sidecar_files "$target_plugin" + if ! validate_browser_client_module_syntax "$target_plugin/scripts/browser-client.mjs" || \ + ! validate_browser_client_trusted_rpc_contract "$target_plugin/scripts/browser-client.mjs"; then + warn "Chrome Browser security-context staging failed closed" + rm -rf "$target_plugin" + return 1 + fi patch_chrome_plugin_for_linux "$target_plugin" - patch_browser_use_node_repl_process_env_import "$target_plugin/scripts/browser-client.mjs" - patch_browser_use_node_repl_config_shim "$target_plugin/scripts/browser-client.mjs" patch_browser_use_native_pipe_import_meta_bridge "$target_plugin/scripts/browser-client.mjs" patch_browser_use_site_status_allowlist_fallback "$target_plugin/scripts/browser-client.mjs" patch_browser_client_linux_socket_dir "$target_plugin/scripts/browser-client.mjs" + if ! validate_browser_client_module_syntax "$target_plugin/scripts/browser-client.mjs" || \ + ! validate_browser_client_trusted_rpc_contract "$target_plugin/scripts/browser-client.mjs"; then + warn "Chrome Browser security-context staging failed closed" + rm -rf "$target_plugin" + return 1 + fi normalize_plugin_script_executable_modes "$target_plugin" if ! install_chrome_extension_host_resource "$target_plugin"; then rm -rf "$target_plugin" @@ -1202,7 +1323,7 @@ if match is None: "WARN: Could not find Browser Use site_status allowlist fallback insertion point — leaving browser-client.mjs unchanged", file=sys.stderr, ) - raise SystemExit(0) + raise SystemExit(2) url = match.group("url") response = match.group("response") @@ -1293,198 +1414,6 @@ print( PY } -patch_browser_use_node_repl_config_shim() { - local client="$1" - - if grep -q "chatgptLinuxBrowserUseConfigShim" "$client"; then - return 0 - fi - - python3 - "$client" <<'PY' -from pathlib import Path -import re -import sys - -path = Path(sys.argv[1]) -source = path.read_text(encoding="utf-8") -pattern = re.compile( - r'function (?P[A-Za-z_$][\w$]*)\(\)\{' - r'let (?P[A-Za-z_$][\w$]*)=globalThis\.nodeRepl;' - r'return (?P=value)\?\.config==null\?void 0:(?P=value)\}' -) -match = pattern.search(source) -if match is None: - print( - "WARN: Could not find Browser Use nodeRepl config shim insertion point — leaving browser-client.mjs unchanged", - file=sys.stderr, - ) - raise SystemExit(0) - -helper = match.group("helper") -value = match.group("value") -shim = r''' -function chatgptLinuxBrowserUseConfigShim() { - let repl = globalThis.nodeRepl; - if (repl == null) return; - chatgptLinuxBrowserUseNodeReplMethodShim(repl); - if (repl.config != null) return; - let config = { - read: async () => ({ config: await chatgptLinuxBrowserUseReadToml("config.toml") }), - readRequirements: async () => ({ requirements: null }), - readToml: async (filePath) => chatgptLinuxBrowserUseReadToml(filePath), - writeToml: chatgptLinuxBrowserUseIgnoreConfigWrite, - writeValue: chatgptLinuxBrowserUseIgnoreConfigWrite, - batchWrite: chatgptLinuxBrowserUseIgnoreConfigWrite, - }; - - try { - repl.config = config; - if (repl.config != null) return; - } catch {} - - try { - let prototype = Object.getPrototypeOf(repl); - if (prototype != null && Object.getOwnPropertyDescriptor(prototype, "config") == null) { - Object.defineProperty(prototype, "config", { - configurable: true, - get: () => config, - }); - } - } catch {} -} - -function chatgptLinuxBrowserUseNodeReplMethodShim(repl) { - // Older Linux node_repl builds do not expose browser notification hooks. - chatgptLinuxBrowserUseDefineNodeReplMethod(repl, "addAfterSubmittedCodeHook", () => () => undefined); -} - -function chatgptLinuxBrowserUseDefineNodeReplMethod(repl, name, value) { - if (typeof repl?.[name] == "function") return; - - try { - repl[name] = value; - if (typeof repl[name] == "function") return; - } catch {} - - try { - let prototype = Object.getPrototypeOf(repl); - if (prototype != null && Object.getOwnPropertyDescriptor(prototype, name) == null) { - Object.defineProperty(prototype, name, { - configurable: true, - value, - }); - } - } catch {} -} - -function chatgptLinuxBrowserUseCodexHome() { - let codexHome = globalThis.nodeRepl?.env?.CODEX_HOME; - if (typeof codexHome == "string" && codexHome.length > 0) { - return codexHome.replace(/\/+$/, ""); - } - - let homeDir = globalThis.nodeRepl?.homeDir; - return typeof homeDir == "string" && homeDir.length > 0 - ? `${homeDir.replace(/\/+$/, "")}/.codex` - : null; -} - -function chatgptLinuxBrowserUseConfigPath(filePath) { - let codexHome = chatgptLinuxBrowserUseCodexHome(); - if (codexHome == null || typeof filePath != "string" || filePath.length === 0) { - return null; - } - - let normalized = filePath.replaceAll("\\", "/"); - if (normalized.startsWith("/")) { - return normalized === codexHome || normalized.startsWith(`${codexHome}/`) - ? normalized - : null; - } - - normalized = normalized.replace(/^\/+/, ""); - return normalized.split("/").includes("..") ? null : `${codexHome}/${normalized}`; -} - -async function chatgptLinuxBrowserUseReadToml(filePath) { - let configPath = chatgptLinuxBrowserUseConfigPath(filePath); - if (configPath == null) return {}; - - try { - let { readFile } = await import("node:fs/promises"); - return chatgptLinuxBrowserUseParseToml(await readFile(configPath, "utf8")); - } catch (error) { - if (error && typeof error == "object" && error.code === "ENOENT") return {}; - throw error; - } -} - -async function chatgptLinuxBrowserUseIgnoreConfigWrite() { - return undefined; -} - -function chatgptLinuxBrowserUseParseToml(source) { - let root = {}; - let section = root; - - for (let line of String(source).split(/\r?\n/)) { - let trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#")) continue; - - let sectionMatch = trimmed.match(/^\[([A-Za-z0-9_.-]+)\]$/); - if (sectionMatch) { - section = root; - for (let part of sectionMatch[1].split(".")) { - section = section[part] && typeof section[part] == "object" && !Array.isArray(section[part]) - ? section[part] - : (section[part] = {}); - } - continue; - } - - let separator = trimmed.indexOf("="); - if (separator < 0) continue; - - let key = trimmed.slice(0, separator).trim(); - let value = trimmed.slice(separator + 1).trim(); - if (key) section[key] = chatgptLinuxBrowserUseParseTomlValue(value); - } - - return root; -} - -function chatgptLinuxBrowserUseParseTomlValue(value) { - if (value === "true") return true; - if (value === "false") return false; - if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); - - if (value.startsWith("[") && value.endsWith("]")) { - let body = value.slice(1, -1).trim(); - return body.length === 0 - ? [] - : body.split(",").map((item) => chatgptLinuxBrowserUseParseTomlValue(item.trim())); - } - - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - try { - return JSON.parse(value); - } catch { - return value.slice(1, -1); - } - } - - return value; -} -''' -replacement = ( - shim - + f'function {helper}(){{chatgptLinuxBrowserUseConfigShim();let {value}=globalThis.nodeRepl;' - + f'return {value}?.config==null?void 0:{value}}}' -) -path.write_text(source[:match.start()] + replacement + source[match.end():], encoding="utf-8") -PY -} - patch_browser_use_native_pipe_import_meta_bridge() { local client="$1" @@ -1611,12 +1540,22 @@ stage_browser_plugin_from_official_app() { rm -rf "$target_plugin" cp -R "$source_plugin" "$target_plugin" remove_macos_sidecar_files "$target_plugin" - patch_browser_use_node_repl_process_env_import "$target_client" - patch_browser_use_node_repl_config_shim "$target_client" + if ! validate_browser_client_module_syntax "$target_client" || \ + ! validate_browser_client_trusted_rpc_contract "$target_client"; then + warn "Browser security-context staging failed closed" + rm -rf "$target_plugin" + return 1 + fi patch_browser_use_native_pipe_import_meta_bridge "$target_client" patch_browser_use_site_status_allowlist_fallback "$target_client" patch_browser_use_file_url_policy "$target_client" patch_browser_client_iab_socket_scope "$target_client" + if ! validate_browser_client_module_syntax "$target_client" || \ + ! validate_browser_client_trusted_rpc_contract "$target_client"; then + warn "Browser security-context staging failed closed" + rm -rf "$target_plugin" + return 1 + fi info "Browser plugin staged from official OpenAI DMG" return 0 diff --git a/scripts/lib/computer-use-plugin-runtime-context.test.js b/scripts/lib/computer-use-plugin-runtime-context.test.js new file mode 100644 index 000000000..b3e7b6427 --- /dev/null +++ b/scripts/lib/computer-use-plugin-runtime-context.test.js @@ -0,0 +1,26 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "../.."); +const manifestPath = path.join( + repositoryRoot, + "plugins/openai-bundled/plugins/computer-use/.mcp.json", +); + +test("Computer Use declares the runtime context required by its authorization client", () => { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const server = manifest.mcpServers?.["computer-use"]; + + assert.ok(server, "Computer Use MCP server manifest is missing"); + assert.ok(Array.isArray(server.env_vars), "Computer Use MCP server declares no env_vars"); + const requiredEnvVars = [ + "CODEX_HOME", + "XDG_RUNTIME_DIR", + "CHATGPT_LINUX_APP_ID", + "CHATGPT_APP_ID", + "CHATGPT_LINUX_INSTANCE_ID", + ]; + assert.deepEqual([...server.env_vars].sort(), [...requiredEnvVars].sort()); +}); diff --git a/scripts/lib/nix-node-repl-elf.sh b/scripts/lib/nix-node-repl-elf.sh new file mode 100644 index 000000000..133375ce7 --- /dev/null +++ b/scripts/lib/nix-node-repl-elf.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +patch_node_repl_elf_for_nix() { + if [ "$#" -ne 3 ]; then + echo "usage: patch_node_repl_elf_for_nix BINARY INTERPRETER RPATH" >&2 + return 2 + fi + + local binary="$1" + local target_interpreter="$2" + local target_rpath="$3" + local magic + local elf_header + local program_headers + local dynamic_section + local entry_point + local interpreter + local actual_interpreter + local actual_rpath + + if [ ! -f "$binary" ]; then + echo "node_repl is not a regular file: $binary" >&2 + return 1 + fi + if ! command -v patchelf >/dev/null 2>&1; then + echo "patchelf is required to classify node_repl: $binary" >&2 + return 1 + fi + if ! command -v readelf >/dev/null 2>&1; then + echo "readelf is required to classify node_repl: $binary" >&2 + return 1 + fi + + magic="$(dd if="$binary" bs=1 count=4 2>/dev/null | od -An -tx1 | tr -d ' \n')" + if [ "$magic" != "7f454c46" ]; then + echo "node_repl is not ELF: $binary" >&2 + return 1 + fi + + if ! elf_header="$(LC_ALL=C readelf -hW "$binary" 2>/dev/null)"; then + echo "could not parse node_repl ELF header: $binary" >&2 + return 1 + fi + if ! program_headers="$(LC_ALL=C readelf -lW "$binary" 2>/dev/null)"; then + echo "could not parse node_repl program headers: $binary" >&2 + return 1 + fi + if ! dynamic_section="$(LC_ALL=C readelf -dW "$binary" 2>/dev/null)"; then + echo "could not parse node_repl dynamic section: $binary" >&2 + return 1 + fi + + if ! printf '%s\n' "$program_headers" | grep -Eq '^[[:space:]]*INTERP[[:space:]]'; then + entry_point="$(printf '%s\n' "$elf_header" | awk -F: ' + /Entry point address:/ { + gsub(/[[:space:]]/, "", $2) + print $2 + exit + } + ')" + if printf '%s\n' "$elf_header" \ + | grep -Eq 'Type:[[:space:]]+DYN[[:space:]]+\(Position-Independent Executable file\)' \ + && [ -n "$entry_point" ] \ + && [ "$entry_point" != "0x0" ] \ + && printf '%s\n' "$dynamic_section" \ + | grep -Eq '\(FLAGS_1\).*Flags:.*[[:space:]]PIE([[:space:]]|$)' \ + && ! printf '%s\n' "$dynamic_section" \ + | grep -Eq '\((NEEDED|RPATH|RUNPATH)\)'; then + return 0 + fi + echo "node_repl is not a verified static PIE executable: $binary" >&2 + return 1 + fi + + if ! interpreter="$(patchelf --print-interpreter "$binary" 2>/dev/null)" \ + || [ -z "$interpreter" ]; then + echo "could not read dynamic node_repl interpreter: $binary" >&2 + return 1 + fi + + if ! patchelf \ + --set-interpreter "$target_interpreter" \ + --set-rpath "$target_rpath" \ + "$binary"; then + echo "could not patch dynamic node_repl for Nix: $binary" >&2 + return 1 + fi + + if ! actual_interpreter="$(patchelf --print-interpreter "$binary" 2>/dev/null)" \ + || [ "$actual_interpreter" != "$target_interpreter" ]; then + echo "node_repl interpreter verification failed: $binary" >&2 + return 1 + fi + if ! actual_rpath="$(patchelf --print-rpath "$binary" 2>/dev/null)" \ + || [ "$actual_rpath" != "$target_rpath" ]; then + echo "node_repl RPATH verification failed: $binary" >&2 + return 1 + fi +} diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 8839f2d41..497e0241d 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -14,6 +14,9 @@ const vm = require("node:vm"); const { applyPetOverlayPatch, } = require("../port-integrations/pet-overlay/patch.js"); +const { + currentComputerUseInstallFlowFixture, +} = require("./patches/impl/computer-use-test-fixtures.js"); // Pin the integration config so a developer's local gitignored integrations.json// cannot change which patch descriptors these core tests exercise. process.env.CHATGPT_PORT_INTEGRATIONS_CONFIG = path.join( @@ -58,6 +61,7 @@ const { const { applyBrowserUseNodeReplApprovalPatch, applyBrowserUseNodeReplApprovalAssets, + applyBrowserUseNodeReplSecurityContextPatch, applyLinuxBundledPluginCopyPermissionsPatch, applyLinuxBundledPluginReconcileStaleSnapshotPatch, applyLinuxBrowserUseRouteLivenessPatch, @@ -1083,6 +1087,7 @@ test("default core patch descriptors are grouped and unique", () => { "linux-chrome-plugin-auto-install", "linux-chrome-native-host-runtime", "browser-use-node-repl-approval", + "linux-browser-use-node-repl-security-context", "linux-bundled-plugin-reconcile-stale-snapshot", "linux-bundled-plugin-copy-permissions", "linux-browser-use-socket-directory", @@ -1168,6 +1173,12 @@ test("default core patch descriptors are grouped and unique", () => { )?.ciPolicy, "required-official-dmg", ); + assert.equal( + descriptors.find( + (descriptor) => descriptor.id === "linux-browser-use-node-repl-security-context", + )?.ciPolicy, + "required-official-dmg", + ); assert.equal( descriptors.find((descriptor) => descriptor.id === "linux-x11-project-picker")?.ciPolicy, "optional", @@ -1183,7 +1194,7 @@ test("default core patch descriptors are grouped and unique", () => { ); assert.equal(computerUseInstallFlow.pattern.test("computer-use-settings-BzkBOuLk.js"), false); assert.equal( - computerUseInstallFlow.assetMatch(currentComputerUseInstallFlow26803Fixture()), + computerUseInstallFlow.assetMatch(currentComputerUseInstallFlowFixture()), true, ); assert.equal(computerUseInstallFlow.assetMatch("function unrelatedAppInitial(){}"), false); @@ -1196,7 +1207,7 @@ test("default core patch descriptors are grouped and unique", () => { ); assert.equal(computerUseHostPlatform.pattern.test("computer-use-settings-BzkBOuLk.js"), false); assert.equal( - computerUseHostPlatform.assetMatch(currentComputerUseHostPlatform26803Fixture()), + computerUseHostPlatform.assetMatch(currentComputerUseHostPlatformFixture()), true, ); assert.equal(computerUseHostPlatform.assetMatch("function unrelatedAppInitial(){}"), false); @@ -1438,7 +1449,7 @@ function currentTrayLifecycleBundleFixture() { "let chatgptLinuxQuitInProgress=!1,chatgptLinuxExplicitQuitApproved=!1,chatgptLinuxMarkQuitInProgress=()=>{chatgptLinuxQuitInProgress=!0},chatgptLinuxPrepareForExplicitQuit=()=>{chatgptLinuxExplicitQuitApproved=!0,chatgptLinuxMarkQuitInProgress()},chatgptLinuxShouldBypassQuitPrompt=()=>chatgptLinuxExplicitQuitApproved===!0,chatgptLinuxIsQuitInProgress=()=>chatgptLinuxQuitInProgress===!0;", "v&&k.on(`close`,e=>{let t=this.getPrimaryWindows().some(e=>e!==k);if((process.platform===`win32`||process.platform===`linux`)&&!this.isAppQuitting&&this.options.canHideLastWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}});", "var H9=null,U9=null,G9=!1;async function fae(e){return G9=!0,U9??H9??(U9=(async()=>{let t={defaultIcon:e},r=new c.Tray(t.defaultIcon,process.platform===`win32`&&c.app.isPackaged?dEe(e.buildFlavor):void 0);if(!G9)return r.destroy(),null;r.setToolTip(c.app.getName());let i=new pb(r);return H9=i,!await i.waitForReady()||H9!==i?(H9===i&&(H9=null,i.destroy()),null):i})().finally(()=>{U9=null}),U9)}", - "var pb=class{constructor(e){this.tray=e;if(process.platform===`linux`){this.tray.on(`click`,()=>{}),this.updatePersistentTrayMenu();return}}destroy(){this.tray.destroy()}isReady(){return process.platform===`linux`&&typeof this.tray.isReady!=`function`?!0:r.S(this.tray)}waitForReady(){return process.platform===`linux`&&typeof this.tray.whenReady!=`function`?Promise.resolve(!0):r.W(this.tray)}getNativeTrayMenuItems(){return[]}updatePersistentTrayMenu(){process.platform===`linux`&&this.tray.setContextMenu(c.Menu.buildFromTemplate(this.getNativeTrayMenuItems()))}}", + "var pb=class{constructor(e){this.tray=e;if(process.platform===`linux`){this.tray.on(`click`,()=>{}),this.updatePersistentTrayMenu();return}}destroy(){this.tray.destroy()}isReady(){if(process.platform!==`linux`)return r.S(this.tray);let e=this.tray;return typeof e.isReady==`function`?e.isReady():!0}async waitForReady(){if(process.platform!==`linux`)return r.W(this.tray);let e=this.tray;if(typeof e.whenReady!=`function`)return!0;try{return await e.whenReady(),!0}catch{return!1}}getNativeTrayMenuItems(){return[]}updatePersistentTrayMenu(){process.platform===`linux`&&this.tray.setContextMenu(c.Menu.buildFromTemplate(this.getNativeTrayMenuItems()))}}", ].join(""); } @@ -1458,7 +1469,7 @@ function singleInstanceBundleFixture() { function explicitQuitBundleFixture() { return [ "var pb=class{getNativeTrayMenuItems(){return[{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}]}};", - "if(o.type===`quit-app`){n.app.quit();return}", + "if(o.type===`quit-app`){o.relaunch===!0&&(e.quitState?.allowQuitTemporarily(),n.app.relaunch()),n.app.quit();return}", ].join(""); } @@ -1637,6 +1648,17 @@ function currentBrowserUseTrustedHashesRuntimeBuilderFixture() { const currentBrowserUseTrustedHashesInsertionRegex = /trustedBrowserClientSha256s:h=\[\],shouldUseWslPaths:f\}\)\{h=chatgptLinuxTrustedBrowserClientSha256s\(h\);return h/; +function currentBrowserUseSecurityContextBuilderFixture() { + return [ + '"use strict";', + 'var Re=`CODEX_CLI_PATH`,ze=`NODE_REPL_NATIVE_PIPE_CONNECT_TIMEOUT_MS`,Be=`NODE_REPL_NODE_MODULE_DIRS`,Ve=`NODE_REPL_NODE_PATH`,He=`NODE_REPL_REQUEST_META`,Ue=`node_repl`,We=`NODE_REPL_SENTRY_USER_ID`,Ge=`NODE_REPL_TRACE_META`,Ke=`NODE_REPL_TRUSTED_CODE_PATHS`,qe=`NODE_REPL_TRUSTED_SERVICES`;', + 'function Je({codexCliPath:e,codexHome:t,envVars:n=[],extraEnv:r,nodeModuleDirs:i=``,nodePath:a,nodeReplPath:o,platform:s,requestMeta:c,sentryUserId:l,traceMeta:u=!1,shouldUseWslPaths:d}){if(a==null||o==null)return null;let f={[ze]:`1000`,[Be]:i,[Ve]:a,[Ke]:Ye([t,i],s),CODEX_HOME:t};return c!=null&&(f[He]=c),l!=null&&(f[We]=l),u&&(f[Ge]=`1`),Object.assign(f,Ze(r)),e!=null&&Xe(s)&&(f[Re]=e),d&&(f.WSLENV=Object.keys(f).map(e=>`${e}/w`).join(`:`)),{[`mcp_servers.${Ue}`]:{args:[],command:o,env:f,...n.length===0?{}:{env_vars:Array.from(n)},startup_timeout_sec:120}}}', + 'var tn=`BROWSER_USE_AVAILABLE_BACKENDS`,iee=`BROWSER_USE_CODEX_APP_BUILD_FLAVOR`,aee=`BROWSER_USE_CODEX_APP_VERSION`,oee=`BROWSER_USE_DISABLE_AMBIENT_NETWORK`,see=`BROWSER_USE_DISABLE_API_MEMBERS`,cee=`BROWSER_USE_DISABLE_BROWSER_CAPABILITIES`,lee=`BROWSER_USE_DISABLE_TAB_CAPABILITIES`,uee=`BROWSER_USE_SECURITY_MODE`;', + 'var nte=[jr,oee,see,cee,lee],rte=[Mr,uee,Nr];', + 'function dte({appVersion:e,availableBrowserUseBackends:t,computerUse:r,enforceModelCheck:i,computerUseNativePipePath:o,computerUsePaths:s,hostServicesPipePath:c,includePrivateProcessEnv:l,runtimePaths:u,sentryUserId:d,shouldUseWslPaths:f}){let p=mte(u.nodeModuleDirs,u.platform),m=n.ei(),h=a.a.resolve(),g=t.length===0?void 0:`${n.Fs({codexHome:m,localVersion:e,marketplaceName:n.js(h),pluginName:n.ys})}/scripts/browser-service.mjs`,_=h===a.a.Dev?S.default.env[He]:void 0,v=a.a.isInternal(h)||S.default.env.NODE_REPL_TRACE_META===`1`,y=S.default.env[Ir]?.trim(),b={[tn]:t.join(`,`),...i?{NODE_REPL_ENFORCE_MODEL_CHECK:`1`}:{},[iee]:h,[aee]:e,[qe]:g==null&&!r?void 0:JSON.stringify({...g==null?{}:{browser:g},...r?{sky:`@oai/sky/service`}:{}}),...ea(nte),...h===a.a.Dev?ea(rte):{},...y?{[Ir]:y}:{}},x=[];return Je({codexCliPath:u.codexCliPath,codexHome:m,envVars:x,extraEnv:b,nodeModuleDirs:p,nodePath:u.nodePath,nodeReplPath:f?n.di(u.nodeReplPath):u.nodeReplPath,platform:u.platform,requestMeta:_,sentryUserId:d,traceMeta:v,shouldUseWslPaths:f})}', + ].join(""); +} + function electron42BrowserUseRuntimeResolverBundleFixture() { return [ "let s=require(`node:path`),l=require(`node:fs`);", @@ -1701,12 +1723,12 @@ function currentComputerUseAuthorityMainBundleFixture() { function currentComputerUseDisableOrderingFixture() { return [ "let dp={dispatchMessage:async()=>{}};async function chatgptLinuxFeatureDispatch(){return dp.dispatchMessage(`electron-desktop-features-changed`,{})}", - "async function Tir(){return null}async function rp(){}async function Lma(){}function oLn(e){return[e]}", - "function Tma(e){let n=e?.hostId??`local`,i={},s=async e=>{let{pluginId:t,enabled:a}=e,c=await Tir(i,n),l=await rp(`batch-write-config-value`,{hostId:n,edits:oLn({pluginId:t,enabled:a}),filePath:c?.filePath??null,expectedVersion:c?.expectedVersion??null,reloadUserConfig:!0});return await Lma(),l};return s}", + "async function q2t(){return null}function KHn(e){return[e]}let Hv={safePost:async()=>{}};function Qg(){return{sendRequest:async()=>{}}}async function Rzr(){}", + "function Tzr(e){let r={},i={},n=e?.hostId??`local`,s=async e=>{let{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c}=e,l=c??s?.plugin;if(l?.source.type===`remote`)await Hv.safePost(`/remote`,{});else{let e=await q2t(r,i,n);await Qg(r,n).sendRequest(`config/batchWrite`,{edits:KHn({pluginId:t,enabled:a}),filePath:e?.filePath??null,expectedVersion:e?.expectedVersion??null,reloadUserConfig:!0})}await Rzr({scope:r,hostId:n,queryClient:i})};return s}", ].join(""); } -function currentComputerUseSettings26803Fixture() { +function currentComputerUseSettingsFixture() { return "let computerUsePluginName=`computer-use`,messagesPluginName=`messages`;function Fn(e){let t=cache(41),{computerUseAvailability:n,platform:r}=e,{selectedHostId:u}=host(),b=[];" + "let x=usePlugins(u,b),S=useMarketplacePath(u),C=flag(firstFlag),w=flag(secondFlag);" + "let{data:te}=query(L),ne=state(Ee),re;" + @@ -2715,11 +2737,11 @@ test("accepts stock Electron tray readiness and prefers the Linux project icon", assert.match( patched, - /isReady\(\)\{return process\.platform===`linux`&&typeof this\.tray\.isReady!=`function`\?!0:r\.S\(this\.tray\)\}/, + /async waitForReady\(\)\{if\(process\.platform!==`linux`\)return r\.W\(this\.tray\);let e=this\.tray;if\(typeof e\.whenReady!=`function`\)return!0;try\{return await e\.whenReady\(\),!0\}catch\{return!1\}\}/, ); assert.match( patched, - /waitForReady\(\)\{return process\.platform===`linux`&&typeof this\.tray\.whenReady!=`function`\?Promise\.resolve\(!0\):r\.W\(this\.tray\)\}/, + /isReady\(\)\{if\(process\.platform!==`linux`\)return r\.S\(this\.tray\);let e=this\.tray;return typeof e\.isReady==`function`\?e\.isReady\(\):!0\}/, ); assert.match( patched, @@ -2732,35 +2754,38 @@ test("accepts stock Electron tray readiness and prefers the Linux project icon", /if\(i\.isEmpty\(\)&&process\.platform===`linux`\)i=c\.nativeImage\.createFromPath/, ); - const readinessMethods = patched.match( - /isReady\(\)\{return process\.platform===`linux`[^}]+\}waitForReady\(\)\{return process\.platform===`linux`[^}]+\}/, - )?.[0]; - assert.ok(readinessMethods); - const evaluateReadiness = async (platform) => { - const context = { process: { platform }, result: null }; - await vm.runInNewContext( - `let r={S:e=>typeof e.isReady==\`function\`?e.isReady():process.platform!==\`linux\`,W:async e=>{if(typeof e.whenReady!=\`function\`)return process.platform!==\`linux\`;try{return await e.whenReady(),!0}catch{return!1}}};class T{constructor(e){this.tray=e}${readinessMethods}}result=(async()=>{let stock=new T({}),native=new T({isReady:()=>!1,whenReady:async()=>{}}),failed=new T({whenReady:async()=>{throw Error(\`not ready\`)}});return{stockWait:await stock.waitForReady(),stockReady:stock.isReady(),nativeWait:await native.waitForReady(),nativeReady:native.isReady(),failedWait:await failed.waitForReady()}})()`, - context, - ); - return JSON.parse(JSON.stringify(await context.result)); - }; - assert.deepEqual( - await evaluateReadiness("linux"), - { - stockWait: true, - stockReady: true, - nativeWait: true, - nativeReady: false, - failedWait: false, + const classStart = patched.indexOf("var pb=class"); + const classEnd = patched.indexOf("v&&k.on", classStart); + assert.notEqual(classStart, -1); + assert.notEqual(classEnd, -1); + const readinessContext = { + c: { Menu: { buildFromTemplate: () => ({}) } }, + process: { platform: "linux" }, + r: { + S: () => { throw new Error("delegated isReady must not run on Linux"); }, + W: () => { throw new Error("delegated waitForReady must not run on Linux"); }, }, + result: null, + }; + vm.runInNewContext( + `${patched.slice(classStart, classEnd)};result=pb`, + readinessContext, ); - assert.deepEqual(await evaluateReadiness("win32"), { - stockWait: true, - stockReady: true, - nativeWait: true, - nativeReady: false, - failedWait: false, + const create = (tray) => new readinessContext.result({ + on() {}, + setContextMenu() {}, + ...tray, }); + const stock = create({}); + const native = create({ isReady: () => false, whenReady: async () => {} }); + const rejected = create({ whenReady: async () => { throw new Error("not ready"); } }); + const thrown = create({ whenReady: () => { throw new Error("not ready"); } }); + assert.equal(stock.isReady(), true); + assert.equal(await stock.waitForReady(), true); + assert.equal(native.isReady(), false); + assert.equal(await native.waitForReady(), true); + assert.equal(await rejected.waitForReady(), false); + assert.equal(await thrown.waitForReady(), false); const iconLoaderStart = patched.indexOf("async function pae("); const iconLoaderEnd = patched.indexOf("var pb=class", iconLoaderStart); @@ -2833,6 +2858,269 @@ test("Linux tray patch preserves dollar sequences from the current bundle", () = assert.ok(patched.includes("`electron-$&`")); }); +test("binds tray readiness to the wrapper instantiated by the tray factory", () => { + const decoy = + "var decoy=class{isReady(){if(process.platform!==`linux`)return x.y(this.tray);let z=this.tray;return typeof z.isReady==`function`?z.isReady():!0}async waitForReady(){if(process.platform!==`linux`)return x.w(this.tray);let q=this.tray;if(typeof q.whenReady!=`function`)return!0;try{return await q.whenReady(),!0}catch{return!1}}};"; + const source = `${currentMainBundlePrefix}${decoy}${trayBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxTrayPatch, + source, + null, + ); + + assert.notEqual(patched, source); + assert.ok(patched.includes(decoy), "must leave the unrelated compatible class unchanged"); + assert.match( + patched, + /var pb=class\{[^]*?isReady\(\)\{if\(process\.platform!==`linux`\)return r\.S\(this\.tray\)/, + ); +}); + +test("binds the current multi-argument tray wrapper by its first tray argument", () => { + const source = `${currentMainBundlePrefix}${trayBundleFixture().replace( + "let i=new pb(r);", + "let i=new pb(r,e.onOpenMainWindow,e.onTrayMenuOpenNewThread);", + )}`; + const patched = applyPatchTwice( + applyLinuxTrayPatch, + source, + null, + ); + + assert.notEqual(patched, source); + assert.match( + patched, + /var pb=class\{[^]*?isReady\(\)\{if\(process\.platform!==`linux`\)return r\.S\(this\.tray\)/, + ); +}); + +test("reports a disconnected executable tray factory as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-tray", + ); + const decoy = [ + "async function disconnectedTrayFactory(e){let fake=new decoy(e);return await fake.waitForReady(),fake}", + "var decoy=class{isReady(){return x.y(this.tray)}waitForReady(){return x.w(this.tray)}};", + ].join(""); + const driftedTray = trayBundleFixture().replace( + "let i=new pb(r);", + "const i=new pb(r);", + ); + const source = `${currentMainBundlePrefix}${decoy}${driftedTray}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal( + report.patches[0]?.reason, + "WARN: Could not find the current tray wrapper class — skipping Linux tray compatibility patch", + ); +}); + +test("finds the current tray wrapper after nested template expressions", () => { + const nestedTemplatePrefix = + "let iconPath=`file://${roots.endsWith(`/`)?roots:`${roots}/`}`;"; + const source = `${currentMainBundlePrefix}${trayBundleFixture().replace( + "async function fae(e){", + `async function fae(e){${nestedTemplatePrefix}`, + )}`; + const patched = applyPatchTwice(applyLinuxTrayPatch, source, null); + + assert.notEqual(patched, source); + assert.match( + patched, + /var pb=class\{[^]*?isReady\(\)\{if\(process\.platform!==`linux`\)return r\.S\(this\.tray\)/, + ); +}); + +test("rejects a readiness decoy when the instantiated tray wrapper drifts", () => { + const decoy = + "var decoy=class{isReady(){return x.y(this.tray)}waitForReady(){return x.w(this.tray)}};"; + const driftedTray = trayBundleFixture().replace( + "isReady(){return r.S(this.tray)}", + "isReady(){return drifted(this.tray)}", + ); + const source = `${currentMainBundlePrefix}${decoy}${driftedTray}`; + const { value: patched, warnings } = captureWarns(() => + applyLinuxTrayPatch(source, null), + ); + + assert.equal(patched, source); + assert.deepEqual(warnings, [ + "WARN: Could not find one unambiguous current Linux tray readiness delegate — skipping Linux tray compatibility patch", + ]); +}); + +test("reports non-executable tray factory decoys as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-tray", + ); + const decoyClass = + "var decoy=class{isReady(){return x.y(this.tray)}waitForReady(){return x.w(this.tray)}};"; + const driftedTray = trayBundleFixture().replace( + "var pb=class{", + "var pb=class extends CurrentTrayWrapper{", + ); + const factoryDecoys = [ + "let factoryDecoy=\"let fake=new decoy(r);return!await fake.waitForReady()?null:fake\";", + "/*let fake=new decoy(r);return!await fake.waitForReady()?null:fake*/", + ]; + + for (const factoryDecoy of factoryDecoys) { + const source = + `${currentMainBundlePrefix}${decoyClass}${factoryDecoy}${driftedTray}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal( + report.patches[0]?.reason, + "WARN: Could not find the current tray wrapper class — skipping Linux tray compatibility patch", + ); + } +}); + +test("reports non-executable tray constructor decoys as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-tray", + ); + const constructorDecoys = [ + 'let constructorDecoy="r=new c.Tray(t.defaultIcon)";', + "/*r=new c.Tray(t.defaultIcon)*/", + ]; + + for (const constructorDecoy of constructorDecoys) { + const driftedTray = trayBundleFixture() + .replace("r=new c.Tray(", "r=createTray(") + .replace( + "async function fae(e){", + `async function fae(e){${constructorDecoy}`, + ); + const source = `${currentMainBundlePrefix}${driftedTray}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal( + report.patches[0]?.reason, + "WARN: Could not find current Linux tray factory — skipping Linux tray retention patch", + ); + } +}); + +test("inserts the tray retention helper outside non-executable decoys", () => { + const helperDecoys = [ + 'let helperDecoy="async function fake(){r=chatgptLinuxRegisterTray(new c.Tray(t.defaultIcon))}";', + "/*async function fake(){r=chatgptLinuxRegisterTray(new c.Tray(t.defaultIcon))}*/", + ]; + + for (const helperDecoy of helperDecoys) { + const source = `${currentMainBundlePrefix}${trayBundleFixture().replace( + "async function fae(e){", + `async function fae(e){${helperDecoy}`, + )}`; + const patched = applyPatchTwice(applyLinuxTrayPatch, source, null); + + assert.notEqual(patched, source); + assert.ok(patched.includes(helperDecoy), "must not rewrite the helper decoy"); + assert.ok( + patched.indexOf("chatgptLinuxRegisterTray=e=>") < + patched.indexOf("async function fae(e){"), + "must insert the executable helper before the real tray factory", + ); + assert.match( + patched, + /r=chatgptLinuxRegisterTray\(new c\.Tray\(t\.defaultIcon/, + ); + } +}); + +test("reports non-executable tray wrapper class decoys as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-tray", + ); + const driftedTray = trayBundleFixture().replace( + "var pb=class{", + "var pb=class extends CurrentTrayWrapper{", + ); + const classDecoys = [ + "let classDecoy=\"var pb=class{isReady(){return x.y(this.tray)}waitForReady(){return x.w(this.tray)}}\";", + "/*var pb=class{isReady(){return x.y(this.tray)}waitForReady(){return x.w(this.tray)}}*/", + ]; + + for (const classDecoy of classDecoys) { + const source = `${currentMainBundlePrefix}${classDecoy}${driftedTray}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal( + report.patches[0]?.reason, + "WARN: Could not find the current tray wrapper class — skipping Linux tray compatibility patch", + ); + } +}); + +test("reports non-executable readiness decoys inside the tray wrapper as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-tray", + ); + const delegatedReadiness = + "isReady(){return r.S(this.tray)}waitForReady(){return r.W(this.tray)}"; + const driftedReadiness = + "isReady(){return drifted(this.tray)}waitForReady(){return driftedWait(this.tray)}"; + const readinessDecoys = [ + `readinessDecoy=${JSON.stringify(delegatedReadiness)};`, + `/*${delegatedReadiness}*/`, + ]; + + for (const readinessDecoy of readinessDecoys) { + const driftedTray = trayBundleFixture().replace( + delegatedReadiness, + `${readinessDecoy}${driftedReadiness}`, + ); + const source = `${currentMainBundlePrefix}${driftedTray}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal( + report.patches[0]?.reason, + "WARN: Could not find one unambiguous current Linux tray readiness delegate — skipping Linux tray compatibility patch", + ); + } +}); + test("retains the current native Linux tray when quit-state helpers already exist", () => { const patched = applyPatchTwice( applyLinuxTrayPatch, @@ -3147,18 +3435,18 @@ test("marks Linux quit-in-progress for the quit-app IPC path", () => { assert.match( patched, - /if\(o\.type===`quit-app`\)\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/, + /if\(o\.type===`quit-app`\)\{o\.relaunch===!0&&\(e\.quitState\?\.allowQuitTemporarily\(\),n\.app\.relaunch\(\)\),typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/, ); }); -test("supports explicit IPC quit patching when minified aliases drift", () => { +test("preserves current relaunch behavior when minified aliases drift", () => { const source = - "let x=require(`electron`);if(m.type===`quit-app`){x.app.quit();return}"; + "let x=require(`electron`);if(m.type===`quit-app`){m.relaunch===!0&&(c.quitState?.allowQuitTemporarily(),x.app.relaunch()),x.app.quit();return}"; const patched = applyPatchTwice(applyLinuxExplicitIpcQuitPatch, source); assert.match( patched, - /if\(m\.type===`quit-app`\)\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),x\.app\.quit\(\);return\}/, + /if\(m\.type===`quit-app`\)\{m\.relaunch===!0&&\(c\.quitState\?\.allowQuitTemporarily\(\),x\.app\.relaunch\(\)\),typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),x\.app\.quit\(\);return\}/, ); }); @@ -3167,8 +3455,8 @@ test("patches remaining explicit quit handlers when another copy is already patc "typeof chatgptLinuxPrepareForExplicitQuit===`function`?chatgptLinuxPrepareForExplicitQuit():typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),"; const patchedTrayQuit = `{label:this.systemQuitMenuItemLabel,click:()=>{${quitMarkerExpression}n.app.quit()}}`; const unpatchedTrayQuit = "{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}"; - const patchedIpcQuit = `if(o.type===\`quit-app\`){${quitMarkerExpression}n.app.quit();return}`; - const unpatchedIpcQuit = "if(o.type===`quit-app`){n.app.quit();return}"; + const patchedIpcQuit = `if(o.type===\`quit-app\`){o.relaunch===!0&&(e.quitState?.allowQuitTemporarily(),n.app.relaunch()),${quitMarkerExpression}n.app.quit();return}`; + const unpatchedIpcQuit = "if(o.type===`quit-app`){o.relaunch===!0&&(e.quitState?.allowQuitTemporarily(),n.app.relaunch()),n.app.quit();return}"; const patchedTray = applyPatchTwice( applyLinuxExplicitTrayQuitPatch, @@ -3187,7 +3475,7 @@ test("patches remaining explicit quit handlers when another copy is already patc assert.equal((patchedIpc.match(/chatgptLinuxPrepareForExplicitQuit\(\)/g) ?? []).length, 2); assert.match( patchedIpc, - /function createSecondIpc\(\)\{if\(o\.type===`quit-app`\)\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}\}/, + /function createSecondIpc\(\)\{if\(o\.type===`quit-app`\)\{o\.relaunch===!0&&\(e\.quitState\?\.allowQuitTemporarily\(\),n\.app\.relaunch\(\)\),typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}\}/, ); }); @@ -3770,6 +4058,21 @@ test("patches current opaque window surface background helper shape for Linux", assert.match(patched, /opaqueWindowSurfaceEnabled:n/); }); +test("rejects computed opaque surface capability expressions", () => { + const source = currentOpaqueWindowSurfaceBackgroundBundle.replace( + "!r.w()", + "!r[`w`]()", + ); + const { value, warnings } = captureWarns(() => + applyLinuxOpaqueBackgroundPatch(source), + ); + + assert.equal(value, source); + assert.deepEqual(warnings, [ + "WARN: Could not find opaque surface mode predicate — skipping Linux opaque surface patch", + ]); +}); + test("keeps the opaque background patch idempotent after pet overlay composition", () => { const source = `${latestAvatarOverlayBundleFixture()}${currentOpaqueWindowSurfaceBackgroundBundle}`; const corePatched = applyLinuxOpaqueBackgroundPatch(source); @@ -9442,8 +9745,8 @@ test("Computer Use availability descriptor matches the current settings bundle n assert.doesNotMatch("use-native-apps.electron-DhuUEit1.js", descriptor.pattern); }); -test("adapts the exact 26.803 Computer Use settings contract without inventing grant state", () => { - const source = currentComputerUseSettings26803Fixture(); +test("adapts the current Computer Use settings contract without inventing grant state", () => { + const source = currentComputerUseSettingsFixture(); const patched = applyPatchTwice(applyLinuxComputerUseRendererAvailabilityPatch, source); assert.match(patched, /BundledMarketplaceDonor/); @@ -9499,7 +9802,7 @@ test("does not reinterpret legacy generated Computer Use output as current", () }); test("reuses current bundled-plugin metadata for the synthetic Computer Use card", async () => { - const source = currentComputerUseSettings26803Fixture(); + const source = currentComputerUseSettingsFixture(); const patched = applyPatchTwice(applyLinuxComputerUseRendererAvailabilityPatch, source); const testHomeDirectory = "/home/test-user"; const bundledMarketplaceRoot = "/tmp/codex-test/openai-bundled"; @@ -9704,10 +10007,18 @@ test("reuses current bundled-plugin metadata for the synthetic Computer Use card "let order=[];" + currentComputerUseDisableOrderingFixture() .replace("dispatchMessage:async()=>{}", "dispatchMessage:async e=>{order.push(e)}") - .replace("async function rp(){}", "async function rp(){order.push(`persist`)}"); + .replace( + "function Qg(){return{sendRequest:async()=>{}}}", + "function Qg(){return{sendRequest:async()=>{order.push(`persist`)}}}", + ); const mutationPatch = applyLinuxComputerUseDisableOrderingPatch(mutationSource); - const mutation = vm.runInNewContext(`${mutationPatch};({mutate:Tma({}),order})`); - await mutation.mutate({ pluginId: plugins[1].plugin.id, enabled: false }); + const mutation = vm.runInNewContext(`${mutationPatch};({mutate:Tzr({}),order})`); + await mutation.mutate({ + pluginId: plugins[1].plugin.id, + enabled: false, + marketplaceAnalytics: {}, + plugin: { source: { type: "local" } }, + }); assert.deepEqual(Array.from(mutation.order), [ "chatgpt-linux-computer-use-disable-requested", "persist", @@ -9748,7 +10059,7 @@ test("does not mistake legacy synthetic Computer Use card paths for the current test("does not treat an unrelated marketplace manifest suffix as patch evidence", () => { const source = "const unrelated=`/.agents/plugins/marketplace.json`;" + - currentComputerUseSettings26803Fixture(); + currentComputerUseSettingsFixture(); const patched = applyLinuxComputerUseRendererAvailabilityPatch(source); @@ -9783,7 +10094,7 @@ test("does not report partial current Computer Use settings patches as applied", ]); }); -function currentComputerUseHostPlatform26803Fixture() { +function currentComputerUseHostPlatformFixture() { return ( "function K3r(e){return e===`macOS`||e===`windows`}" + "function q3r(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;" + @@ -9796,23 +10107,10 @@ function currentComputerUseHostPlatform26803Fixture() { ); } -function currentComputerUseInstallFlow26803Fixture() { - return ( - "function i4i(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e," + - "s=o===void 0?!0:o,c=n??`local`,l;t[0]===c?l=t[1]:(l={hostId:c},t[0]=c,t[1]=l);" + - "let u=hostReady(l),d=environment(),f;t[2]===i?f=t[3]:(f=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=f);" + - "let p=f,m;t[4]!==c||t[5]!==p?(m={enabled:p,hostId:c},t[4]=c,t[5]=p,t[6]=m):m=t[6];" + - "let h=useComputerUseAvailability(m),g=(r!=null||a!=null)&&i!=null,v=u&&s&&g&&(!p||h.available);" + - "let b=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);" + - "return read(`read-plugin`,{hostId:c,...pluginLocation({marketplacePath:r,remoteMarketplaceName:a}),pluginName:i})};" + - "return useQuery({queryFn:b,enabled:v})}" - ); -} - -test("allows the exact 26.803 Computer Use host platform contract on Linux", () => { +test("allows the current Computer Use host platform contract on Linux", () => { const patched = applyPatchTwice( applyLinuxComputerUseHostPlatformPatch, - currentComputerUseHostPlatform26803Fixture(), + currentComputerUseHostPlatformFixture(), ); assert.match( @@ -9854,7 +10152,7 @@ test("rejects current Computer Use host-platform drift byte-identically", () => }); test("rejects a one-argument Computer Use platform predicate with changed semantics", () => { - const source = currentComputerUseHostPlatform26803Fixture().replace( + const source = currentComputerUseHostPlatformFixture().replace( "function K3r(e){return e===`macOS`||e===`windows`}", "function K3r(e){return accountEligible(e)}", ); @@ -9869,36 +10167,36 @@ test("rejects a one-argument Computer Use platform predicate with changed semant ]); }); -test("loads the exact 26.803 Computer Use plugin detail contract on Linux", async () => { +test("loads the current Computer Use plugin detail contract on Linux", async () => { const patched = applyPatchTwice( applyLinuxComputerUseInstallFlowPatch, - currentComputerUseInstallFlow26803Fixture(), + currentComputerUseInstallFlowFixture(), ); - assert.match(patched, /let p=f&&i!==`computer-use`,m;/); - assert.doesNotMatch(patched, /let p=f,m;/); + assert.match(patched, /let m=p&&i!==`computer-use`,h;/); + assert.doesNotMatch(patched, /let m=p,h;/); const marketplacePath = "/tmp/codex-test/openai-bundled/.agents/plugins/marketplace.json"; let pluginRead = null; const query = vm.runInNewContext( - `${patched};i4i(${JSON.stringify({ + `${patched};currentPluginDetail(${JSON.stringify({ hostId: "local", marketplacePath, pluginName: "computer-use", })})`, { cache: (size) => new Array(size), - environment: () => "desktop", - hostReady: () => true, - isAvailabilityGated: () => true, - pluginLocation: ({ marketplacePath: selectedMarketplacePath }) => ({ - marketplacePath: selectedMarketplacePath, + client: () => ({ + sendRequest: async (method, params) => { + pluginRead = { method, params }; + return { plugin: { name: "computer-use" } }; + }, }), - read: async (method, params) => { - pluginRead = { method, params }; - return { plugin: { name: "computer-use" } }; - }, + getScope: () => ({}), + hostReady: () => true, + pluginBaseName: (name) => name, + queryClient: () => ({}), useComputerUseAvailability: () => ({ available: false }), useQuery: (options) => options, }, @@ -9909,9 +10207,8 @@ test("loads the exact 26.803 Computer Use plugin detail contract on Linux", asyn assert.deepEqual( JSON.parse(JSON.stringify(pluginRead)), { - method: "read-plugin", + method: "plugin/read", params: { - hostId: "local", marketplacePath, pluginName: "computer-use", }, @@ -10158,6 +10455,96 @@ test("uses xdg-open path when CHATGPT_LINUX_DISABLE_EXTERNAL_OPEN_PATCH is not 1 assert.equal(spawnCalls[0].command, "xdg-open"); }); +test("accepts the current Browser trusted-service producer unchanged", () => { + const source = currentBrowserUseSecurityContextBuilderFixture(); + const patched = applyPatchTwice(applyBrowserUseNodeReplSecurityContextPatch, source); + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-browser-use-node-repl-security-context", + ); + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors(source, [descriptor], {}, report); + + assert.equal(patched, source); + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "already-applied"); + assert.match(patched, /NODE_REPL_TRUSTED_SERVICES/); + assert.match(patched, /NODE_REPL_TRUSTED_CODE_PATHS/); + assert.doesNotMatch(patched, /chatgptLinuxBrowserUseRequestMeta|chatgpt\/browser-runtime-context/); +}); + +test("rejects non-executable and ambiguous Browser trusted-service producers", () => { + const current = currentBrowserUseSecurityContextBuilderFixture(); + const cases = [ + `/*${current}*/`, + `const decoy=${JSON.stringify(current)};`, + `const decoy=\`outer \${\`${current}\`} tail\`;`, + `${current}${current}`, + ]; + + for (const source of cases) { + assert.throws( + () => applyBrowserUseNodeReplSecurityContextPatch(source), + /Browser Use node_repl trusted-service producer.*environment key bindings/, + ); + } +}); + +test("rejects drift in each Browser trusted-service security boundary", () => { + const current = currentBrowserUseSecurityContextBuilderFixture(); + const cases = [ + [ + current.replace("/scripts/browser-service.mjs", "/scripts/browser-client.mjs"), + "browser service path", + ], + [current.replace("Ye([t,i],s)", "Ye([t],s)"), "trusted code paths assignment"], + [ + current.replace("...h===a.a.Dev?ea(rte):{}", "...ea(rte)"), + "development environment forwarding", + ], + [ + current.replace("nte=[jr,oee,see,cee,lee]", "nte=[jr,oee,see,cee,lee,uee]"), + "security mode restricted to development", + ], + [current.replace("[aee]:e", "[aee]:`unknown`"), "boundary environment fields"], + [ + current.replace("extraEnv:b,nodeModuleDirs:p", "extraEnv:{},nodeModuleDirs:p"), + "runtime builder call", + ], + [ + current.replace( + "nodeModuleDirs:p,nodePath:u.nodePath", + "nodeModuleDirs:`other`,nodePath:u.nodePath", + ), + "runtime builder call", + ], + [current.replace("nodePath:u.nodePath", "nodePath:`node`"), "runtime builder call"], + ]; + + for (const [source, failedCheck] of cases) { + assert.notEqual(source, current); + assert.throws( + () => applyBrowserUseNodeReplSecurityContextPatch(source), + new RegExp(`Browser Use node_repl trusted-service producer.*${failedCheck}`), + ); + } +}); + +test("reports malformed Browser security-context producer contracts as failed-required", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-browser-use-node-repl-security-context", + ); + const source = `${currentBrowserUseSecurityContextBuilderFixture()}${currentBrowserUseSecurityContextBuilderFixture()}`; + const report = createPatchReport(); + const result = applyMainBundlePatchDescriptors(source, [descriptor], {}, report); + + assert.equal(result.patchedSource, source); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.match( + report.patches[0]?.reason ?? "", + /Browser Use node_repl trusted-service producer/, + ); +}); + test("trusts the current direct Browser Use node_repl runtime config builder", () => { const source = currentBrowserUseTrustedHashesRuntimeBuilderFixture(); @@ -10540,10 +10927,10 @@ test("authority drift fails required generation without exposing partial Compute computerUseGateBundleFixture(), "let cp=require(`node:child_process`),cuOs=require(`node:os`);var cuHandlers={handlers:{\"native-desktop-apps\":async()=>({apps:[]})}};", ].join(""); - const settingsSource = currentComputerUseSettings26803Fixture(); - const appInitialSource = currentComputerUseHostPlatform26803Fixture() + + const settingsSource = currentComputerUseSettingsFixture(); + const appInitialSource = currentComputerUseHostPlatformFixture() + currentComputerUseDisableOrderingFixture() + - currentComputerUseInstallFlow26803Fixture(); + currentComputerUseInstallFlowFixture(); fs.writeFileSync(path.join(buildDir, "main.js"), mainSource); fs.writeFileSync(path.join(assetsDir, "computer-use-settings-test.js"), settingsSource); fs.writeFileSync(path.join(assetsDir, "app-initial-test.js"), appInitialSource); @@ -10594,10 +10981,10 @@ test("avatar cursor drift fails required generation without exposing partial Com computerUseGateBundleFixture(), "let cp=require(`node:child_process`),cuOs=require(`node:os`);var cuHandlers={handlers:{\"native-desktop-apps\":async()=>({apps:[]})}};", ].join(""); - const settingsSource = currentComputerUseSettings26803Fixture(); - const appInitialSource = currentComputerUseHostPlatform26803Fixture() + + const settingsSource = currentComputerUseSettingsFixture(); + const appInitialSource = currentComputerUseHostPlatformFixture() + currentComputerUseDisableOrderingFixture() + - currentComputerUseInstallFlow26803Fixture(); + currentComputerUseInstallFlowFixture(); fs.writeFileSync(path.join(buildDir, "main.js"), mainSource); fs.writeFileSync(path.join(assetsDir, "computer-use-settings-test.js"), settingsSource); fs.writeFileSync(path.join(assetsDir, "app-initial-test.js"), appInitialSource); @@ -10629,7 +11016,7 @@ test("avatar cursor drift fails required generation without exposing partial Com } }); -test("patchExtractedApp selects the exact 26.803 Computer Use app-initial contract", async () => { +test("patchExtractedApp selects the current Computer Use app-initial contract", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-computer-use-apps-assets-test-")); try { const buildDir = path.join(tempRoot, ".vite", "build"); @@ -10654,12 +11041,12 @@ test("patchExtractedApp selects the exact 26.803 Computer Use app-initial contra ); fs.writeFileSync( path.join(assetsDir, "computer-use-settings-BzkBOuLk.js"), - currentComputerUseSettings26803Fixture(), + currentComputerUseSettingsFixture(), ); const appInitialSource = - currentComputerUseHostPlatform26803Fixture() + + currentComputerUseHostPlatformFixture() + currentComputerUseDisableOrderingFixture() + - currentComputerUseInstallFlow26803Fixture(); + currentComputerUseInstallFlowFixture(); fs.writeFileSync( path.join(assetsDir, "app-initial-BHB6SClA.js"), appInitialSource, @@ -10691,7 +11078,7 @@ test("patchExtractedApp selects the exact 26.803 Computer Use app-initial contra /o===`linux`&&\(a=\{\.\.\.a,available:!0,isFetching:!1,isLoading:!1\}\);/, ); assert.match(patchedSettings, /marketplaceName:`openai-bundled`/); - assert.match(patchedAppInitial, /let p=f&&i!==`computer-use`,m;/); + assert.match(patchedAppInitial, /let m=p&&i!==`computer-use`,h;/); assert.match(patchedAppInitial, /chatgpt-linux-computer-use-disable-before-write/); assert.match( patchedAppInitial, diff --git a/scripts/patches/core/all-linux/main-process/browser-integrations/patch.js b/scripts/patches/core/all-linux/main-process/browser-integrations/patch.js index d9db51bf1..7d4476770 100644 --- a/scripts/patches/core/all-linux/main-process/browser-integrations/patch.js +++ b/scripts/patches/core/all-linux/main-process/browser-integrations/patch.js @@ -7,6 +7,7 @@ const { const { patchStatusFromChange } = require("../../../../../lib/patch-report.js"); const { applyBrowserUseNodeReplApprovalAssets, + applyBrowserUseNodeReplSecurityContextPatch, applyLinuxBundledPluginCopyPermissionsPatch, applyLinuxBundledPluginReconcileStaleSnapshotPatch, applyLinuxBrowserUseRouteLivenessPatch, @@ -39,6 +40,13 @@ module.exports = [ : warnings[0] ?? null, }), }), + mainBundlePatch({ + id: "linux-browser-use-node-repl-security-context", + phase: "main-bundle", + order: 162, + ciPolicy: "required-official-dmg", + apply: applyBrowserUseNodeReplSecurityContextPatch, + }), mainBundlePatch({ id: "linux-bundled-plugin-reconcile-stale-snapshot", phase: "main-bundle", diff --git a/scripts/patches/impl/computer-use-authority.test.js b/scripts/patches/impl/computer-use-authority.test.js index 470273c53..f836509fc 100644 --- a/scripts/patches/impl/computer-use-authority.test.js +++ b/scripts/patches/impl/computer-use-authority.test.js @@ -711,57 +711,20 @@ test("support matching rejects partially stale authority and cursor patches", () ); }); -test("revokes the official Computer Use plugin before its persisted config write", async () => { - const source = [ - "let order=[];let dp={dispatchMessage:async e=>{order.push(e)}};", - "async function feature(){return dp.dispatchMessage(`electron-desktop-features-changed`,{})}", - "async function Tir(){return null}async function rp(){order.push(`persist`)}async function Lma(){}function oLn(e){return[e]}", - "function Tma(e){let n=e?.hostId??`local`,i={},o={},r={},s=async e=>{let{pluginId:t,enabled:a}=e,c=await Tir(i,n),l=await rp(`batch-write-config-value`,{hostId:n,edits:oLn({pluginId:t,enabled:a}),filePath:c?.filePath??null,expectedVersion:c?.expectedVersion??null,reloadUserConfig:!0});return await Lma(),l};return s}", - ].join(""); - const patched = applyLinuxComputerUseDisableOrderingPatch(source); - const api = vm.runInNewContext(`${patched};({mutate:Tma({}),order})`); - - await api.mutate({ pluginId: "computer-use@openai-bundled", enabled: false }); - assert.deepEqual(Array.from(api.order), [ - "chatgpt-linux-computer-use-disable-requested", - "persist", - ]); - api.order.length = 0; - await api.mutate({ pluginId: "computer-use@openai-bundled", enabled: true }); - assert.deepEqual(Array.from(api.order), ["persist"]); -}); - -test("patches the current nested Computer Use plugin edit contract", () => { - const source = [ - "let dp={dispatchMessage:async()=>{}};", - "async function feature(){return dp.dispatchMessage(`electron-desktop-features-changed`,{})}", - "async function W6n(){}async function dm(){}async function Qea(){}function oLn(e){return[e]}", - "function Bea(e){let n=e?.hostId??`local`,i={},s=async e=>{let{pluginId:t,enabled:a}=e,c=await W6n(i,n),l=await dm(`batch-write-config-value`,{hostId:n,edits:oLn({pluginId:t,enabled:a}),filePath:c?.filePath??null,expectedVersion:c?.expectedVersion??null,reloadUserConfig:!0});return await Qea(),l};return s}", - ].join(""); - - const patched = applyLinuxComputerUseDisableOrderingPatch(source); - - assert.equal(matchesLinuxComputerUseDisableOrderingContract(patched), true); - assert.match( - patched, - /chatgpt-linux-computer-use-disable-before-write/, - ); -}); - test("patches an unassigned Computer Use plugin write without dropping extra bindings", async () => { const source = [ "let order=[];let dp={dispatchMessage:async e=>{order.push(e)}};", "async function feature(){return dp.dispatchMessage(`electron-desktop-features-changed`,{})}", - "async function w_n(){return null}async function cm(){order.push(`persist`)}async function ipa(){}function I2n(e){return[e]}", - "function Kfa(e){let n=e?.hostId??`local`,r={},i={},o={},s=async e=>{let{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c}=e,l=await w_n(i,n);await cm(`batch-write-config-value`,{hostId:n,edits:I2n({pluginId:t,enabled:a}),filePath:l?.filePath??null,expectedVersion:l?.expectedVersion??null,reloadUserConfig:!0}),await ipa({scope:r,hostId:n,intl:o,queryClient:i})};return s}", + "async function q2t(){order.push(`read`);return null}function KHn(e){return[e]}let Hv={safePost:async()=>{order.push(`remote`)}};function Qg(){return{sendRequest:async e=>{order.push(e)}}}async function Rzr(){order.push(`refresh`)}", + "function Tzr(e){let r={},i={},n=e?.hostId??`local`,s=async e=>{let{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c}=e,l=c??s?.plugin;if(l?.source.type===`remote`)await Hv.safePost(`/remote`,{});else{let e=await q2t(r,i,n);await Qg(r,n).sendRequest(`config/batchWrite`,{edits:KHn({pluginId:t,enabled:a}),filePath:e?.filePath??null,expectedVersion:e?.expectedVersion??null,reloadUserConfig:!0})}await Rzr({scope:r,hostId:n,queryClient:i})};return s}", ].join(""); const patched = applyLinuxComputerUseDisableOrderingPatch(source); - const api = vm.runInNewContext(`${patched};({mutate:Kfa({}),order})`); + const api = vm.runInNewContext(`${patched};({mutate:Tzr({}),order})`); assert.match( patched, - /let\{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c\}=e;/, + /let\{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c\}=e,l=c\?\?s\?\.plugin;/, ); assert.equal(matchesLinuxComputerUseDisableOrderingContract(patched), true); assert.equal(applyLinuxComputerUseDisableOrderingPatch(patched), patched); @@ -769,29 +732,39 @@ test("patches an unassigned Computer Use plugin write without dropping extra bin await api.mutate({ enabled: false, marketplaceAnalytics: {}, - plugin: {}, + plugin: { source: { type: "local" } }, pluginId: "computer-use@openai-bundled", }); assert.deepEqual(Array.from(api.order), [ "chatgpt-linux-computer-use-disable-requested", - "persist", + "read", + "config/batchWrite", + "refresh", ]); api.order.length = 0; await api.mutate({ enabled: true, marketplaceAnalytics: {}, - plugin: {}, + plugin: { source: { type: "local" } }, pluginId: "computer-use@openai-bundled", }); - assert.deepEqual(Array.from(api.order), ["persist"]); + assert.deepEqual(Array.from(api.order), ["read", "config/batchWrite", "refresh"]); + api.order.length = 0; + await api.mutate({ + enabled: false, + marketplaceAnalytics: {}, + plugin: { source: { type: "remote" } }, + pluginId: "unrelated-plugin", + }); + assert.deepEqual(Array.from(api.order), ["remote", "refresh"]); }); test("disable-before-write rejects marker-only state and ignores marker decoys", () => { const mutationSource = [ "let dp={dispatchMessage:async()=>{}};", "async function feature(){return dp.dispatchMessage(`electron-desktop-features-changed`,{})}", - "async function Tir(){}async function rp(){}async function Lma(){}function oLn(e){return[e]}", - "function Tma(e){let n=e?.hostId??`local`,i={},s=async e=>{let{pluginId:t,enabled:a}=e,c=await Tir(i,n),l=await rp(`batch-write-config-value`,{hostId:n,edits:oLn({pluginId:t,enabled:a}),filePath:c?.filePath??null,expectedVersion:c?.expectedVersion??null,reloadUserConfig:!0});return await Lma(),l};return s}", + "async function q2t(){}function KHn(e){return[e]}let Hv={safePost:async()=>{}};function Qg(){return{sendRequest:async()=>{}}}async function Rzr(){}", + "function Tzr(e){let r={},i={},n=e?.hostId??`local`,s=async e=>{let{pluginId:t,enabled:a,marketplaceAnalytics:s,plugin:c}=e,l=c??s?.plugin;if(l?.source.type===`remote`)await Hv.safePost(`/remote`,{});else{let e=await q2t(r,i,n);await Qg(r,n).sendRequest(`config/batchWrite`,{edits:KHn({pluginId:t,enabled:a}),filePath:e?.filePath??null,expectedVersion:e?.expectedVersion??null,reloadUserConfig:!0})}await Rzr({scope:r,hostId:n,queryClient:i})};return s}", ].join(""); const markerOnly = "/*chatgpt-linux-computer-use-disable-before-write*/" + mutationSource; @@ -847,8 +820,8 @@ test("disable-before-write rejects marker-only state and ignores marker decoys", ); const persistenceCommentDecoy = mutationSource.replace( - "l=await rp(`batch-write-config-value`,{hostId:n,edits:oLn({pluginId:t,enabled:a}),filePath:c?.filePath??null,expectedVersion:c?.expectedVersion??null,reloadUserConfig:!0})", - "l=await rp(`unrelated`,{});/*`batch-write-config-value`,{reloadUserConfig:!0}*/l", + "await Qg(r,n).sendRequest(`config/batchWrite`,{edits:KHn({pluginId:t,enabled:a}),filePath:e?.filePath??null,expectedVersion:e?.expectedVersion??null,reloadUserConfig:!0})", + "await Qg(r,n).sendRequest(`unrelated`,{});/*`config/batchWrite`,{reloadUserConfig:!0}*/", ); assert.equal( matchesLinuxComputerUseDisableOrderingContract(persistenceCommentDecoy), @@ -858,6 +831,16 @@ test("disable-before-write rejects marker-only state and ignores marker decoys", () => applyLinuxComputerUseDisableOrderingPatch(persistenceCommentDecoy), /unavailable or ambiguous/, ); + + const mutationStart = mutationSource.indexOf("function Tzr"); + const ambiguousMutation = mutationSource + mutationSource + .slice(mutationStart) + .replace("function Tzr", "function Uzr"); + assert.equal(matchesLinuxComputerUseDisableOrderingContract(ambiguousMutation), false); + assert.throws( + () => applyLinuxComputerUseDisableOrderingPatch(ambiguousMutation), + /unavailable or ambiguous/, + ); }); test("gates native desktop and icon handlers before backend or file access", async () => { diff --git a/scripts/patches/impl/computer-use-install-flow.test.js b/scripts/patches/impl/computer-use-install-flow.test.js new file mode 100644 index 000000000..159196a11 --- /dev/null +++ b/scripts/patches/impl/computer-use-install-flow.test.js @@ -0,0 +1,149 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const vm = require("node:vm"); + +const { + applyLinuxComputerUseInstallFlowPatch, + matchesLinuxComputerUseInstallFlowContract, +} = require("./computer-use.js"); +const { + currentComputerUseInstallFlowFixture, +} = require("./computer-use-test-fixtures.js"); + +function captureWarnings(fn) { + const originalWarn = console.warn; + const warnings = []; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +test("loads current Computer Use plugin details without platform availability", async () => { + const source = currentComputerUseInstallFlowFixture(); + + assert.equal(matchesLinuxComputerUseInstallFlowContract(source), true); + const patched = applyLinuxComputerUseInstallFlowPatch(source); + assert.match(patched, /let m=p&&i!==`computer-use`,h;/); + assert.equal(applyLinuxComputerUseInstallFlowPatch(patched), patched); + + let readRequest = null; + const query = vm.runInNewContext( + `${patched};currentPluginDetail(${JSON.stringify({ + hostId: "local", + marketplacePath: "/tmp/openai-bundled/.agents/plugins/marketplace.json", + pluginName: "computer-use", + })})`, + { + cache: (size) => new Array(size), + client: () => ({ + sendRequest: async (method, params) => { + readRequest = { method, params }; + return { plugin: { name: "computer-use" } }; + }, + }), + getScope: () => ({}), + hostReady: () => true, + pluginBaseName: (name) => name, + queryClient: () => ({}), + useComputerUseAvailability: () => ({ available: false, isLoading: false }), + useQuery: (options) => options, + }, + ); + + assert.equal(query.enabled, true); + await query.queryFn(); + assert.deepEqual(JSON.parse(JSON.stringify(readRequest)), { + method: "plugin/read", + params: { + marketplacePath: "/tmp/openai-bundled/.agents/plugins/marketplace.json", + pluginName: "computer-use", + }, + }); +}); + +test("preserves unrelated plugin detail availability gates", () => { + const source = currentComputerUseInstallFlowFixture().replace( + "p=i!=null&&isComputerUsePlugin(i),", + "p=i!=null&&isUnrelatedPlugin(i),", + ); + + assert.equal(matchesLinuxComputerUseInstallFlowContract(source), false); + const { value, warnings } = captureWarnings(() => + applyLinuxComputerUseInstallFlowPatch(source), + ); + assert.equal(value, source); + assert.deepEqual(warnings, [ + "WARN: Could not find current Computer Use plugin detail availability gate — skipping Linux Computer Use install flow patch", + ]); +}); + +test("ignores non-executable current install-flow anchors", () => { + const driftedGate = currentComputerUseInstallFlowFixture().replace( + "p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;", + "p=i!=null&&isAvailabilityGated(i),t[3]=i,t[4]=p);let m=p,h;", + ); + const cases = [ + driftedGate.replace( + "function currentPluginDetail(e){", + 'function currentPluginDetail(e){let decoy="p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;";', + ), + driftedGate.replace( + "function currentPluginDetail(e){", + "function currentPluginDetail(e){/*p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;*/", + ), + currentComputerUseInstallFlowFixture() + .replace( + "function isComputerUsePlugin(e){return pluginBaseName(e)===computerUsePluginName}", + "function isOtherPlugin(e){return pluginBaseName(e)===computerUsePluginName}", + ) + .replace( + "function currentPluginDetail(e){", + "function currentPluginDetail(e){/*function isComputerUsePlugin(e){return pluginBaseName(e)===computerUsePluginName}*/", + ), + ]; + + for (const source of cases) { + assert.equal(matchesLinuxComputerUseInstallFlowContract(source), false); + const { value, warnings } = captureWarnings(() => + applyLinuxComputerUseInstallFlowPatch(source), + ); + assert.equal(value, source); + assert.deepEqual(warnings, [ + "WARN: Could not find current Computer Use plugin detail availability gate — skipping Linux Computer Use install flow patch", + ]); + } +}); + +test("accepts the executable install-flow contract alongside non-executable decoys", () => { + const source = currentComputerUseInstallFlowFixture().replace( + "function currentPluginDetail(e){", + 'function currentPluginDetail(e){let decoy="p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;";/*p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;*/', + ); + + assert.equal(matchesLinuxComputerUseInstallFlowContract(source), true); + const { value, warnings } = captureWarnings(() => + applyLinuxComputerUseInstallFlowPatch(source), + ); + assert.notEqual(value, source); + assert.match(value, /let m=p&&i!==`computer-use`,h;/u); + assert.deepEqual(warnings, []); +}); + +test("rejects a patched gate bound to a different plugin name variable", () => { + const source = currentComputerUseInstallFlowFixture().replace( + "let m=p,h;", + "let m=p&&a!==`computer-use`,h;", + ); + + assert.equal(matchesLinuxComputerUseInstallFlowContract(source), false); + const { value, warnings } = captureWarnings(() => + applyLinuxComputerUseInstallFlowPatch(source), + ); + assert.equal(value, source); + assert.equal(warnings.length, 1); +}); diff --git a/scripts/patches/impl/computer-use-test-fixtures.js b/scripts/patches/impl/computer-use-test-fixtures.js new file mode 100644 index 000000000..1d16fcefc --- /dev/null +++ b/scripts/patches/impl/computer-use-test-fixtures.js @@ -0,0 +1,22 @@ +"use strict"; + +function currentComputerUseInstallFlowFixture() { + return ( + "function currentPluginDetail(e){let t=cache(28),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e," + + "s=o===void 0||o,c=n??`local`,scope=getScope();let u={enabled:!0,hostId:c},d=hostReady(u),f=queryClient(),p;" + + "t[3]===i?p=t[4]:(p=i!=null&&isComputerUsePlugin(i),t[3]=i,t[4]=p);let m=p,h;" + + "h={enabled:m,hostId:c};let g=useComputerUseAvailability(h),_=(r!=null||a!=null)&&i!=null," + + "v=d&&s&&_&&m&&g.isLoading,y=d&&s&&_&&(!m||g.available),b=" + + "pluginDetailQuery({scope:scope,accountId:null,hostId:c,marketplacePath:r,pluginName:i,queryClient:f,remoteMarketplaceName:a});" + + "return useQuery({...b,enabled:y})}" + + "function pluginDetailQuery({scope:e,accountId:t,hostId:n,marketplacePath:r,pluginName:i,queryClient:a,remoteMarketplaceName:o}){" + + "return{queryFn:async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);" + + "return client(e,n).sendRequest(`plugin/read`,{marketplacePath:r,pluginName:i,remoteMarketplaceName:o})}}}" + + "var computerUsePluginName;computerUsePluginName=`computer-use`;" + + "function isComputerUsePlugin(e){return pluginBaseName(e)===computerUsePluginName}" + ); +} + +module.exports = { + currentComputerUseInstallFlowFixture, +}; diff --git a/scripts/patches/impl/computer-use.js b/scripts/patches/impl/computer-use.js index 66c55c7f7..59ff35ddb 100644 --- a/scripts/patches/impl/computer-use.js +++ b/scripts/patches/impl/computer-use.js @@ -250,42 +250,106 @@ function executableRegexMatches(currentSource, pattern) { } function findLinuxComputerUsePluginConfigMutations(currentSource, patched) { - const pattern = patched - ? /let\{pluginId:([A-Za-z_$][\w$]*),enabled:([A-Za-z_$][\w$]*)((?:,[A-Za-z_$][\w$]*:[A-Za-z_$][\w$]*)*)\}=([A-Za-z_$][\w$]*);if\(\1===`computer-use@openai-bundled`&&\2!==!0\)await ([A-Za-z_$][\w$]*)\.dispatchMessage\(`chatgpt-linux-computer-use-disable-requested`,\{\}\);\/\*chatgpt-linux-computer-use-disable-before-write\*\/let ([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*)\(([^()]*)\)(?:,([A-Za-z_$][\w$]*)=await |;await )([A-Za-z_$][\w$]*)\(`batch-write-config-value`,/g - : /let\{pluginId:([A-Za-z_$][\w$]*),enabled:([A-Za-z_$][\w$]*)((?:,[A-Za-z_$][\w$]*:[A-Za-z_$][\w$]*)*)\}=([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*)\(([^()]*)\)(?:,([A-Za-z_$][\w$]*)=await |;await )([A-Za-z_$][\w$]*)\(`batch-write-config-value`,/g; + const directPattern = + /let\{pluginId:([A-Za-z_$][\w$]*),enabled:([A-Za-z_$][\w$]*)([^{}]*)\}=([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\?\?([A-Za-z_$][\w$]*)\?\.plugin;/g; const results = []; - for (const match of executableRegexMatches(currentSource, pattern)) { + for (const match of executableRegexMatches(currentSource, directPattern)) { if (match.index == null) continue; - const optionsStart = match.index + match[0].length; + const idVar = match[1]; + const enabledVar = match[2]; + const destructuringSuffix = match[3]; + const argumentVar = match[4]; + const pluginStateVar = match[5]; + const pluginVar = match[6]; + const analyticsVar = match[7]; + if ( + !new RegExp(`(?:^|,)marketplaceAnalytics:${escapeRegExp(analyticsVar)}(?:,|$)`).test( + destructuringSuffix, + ) || + !new RegExp(`(?:^|,)plugin:${escapeRegExp(pluginVar)}(?:,|$)`).test( + destructuringSuffix, + ) + ) { + continue; + } + + let branchStart = match.index + match[0].length; + let dispatchVar = null; + if (patched) { + const patchedPrefix = new RegExp( + `^if\\(${escapeRegExp(idVar)}===\\\`computer-use@openai-bundled\\\`&&${escapeRegExp(enabledVar)}!==!0\\)await ([A-Za-z_$][\\w$]*)\\.dispatchMessage\\(\\\`chatgpt-linux-computer-use-disable-requested\\\`,\\{\\}\\);/\\*${LINUX_COMPUTER_USE_DISABLE_ORDERING_MARKER}\\*/`, + ); + const patchedMatch = currentSource.slice(branchStart).match(patchedPrefix); + if (patchedMatch == null) continue; + dispatchVar = patchedMatch[1]; + branchStart += patchedMatch[0].length; + } + + const containing = findContainingFunction(currentSource, match.index); + if (containing == null || branchStart >= containing.closeIndex) continue; + const remotePrefix = `if(${pluginStateVar}?.source.type===\`remote\`)await `; + if (currentSource.slice(branchStart, branchStart + remotePrefix.length) !== remotePrefix) { + continue; + } + let elseIndex = currentSource.indexOf(";else{", branchStart + remotePrefix.length); + while ( + elseIndex >= 0 && + elseIndex < containing.closeIndex && + findExecutableJavaScriptSubstring(currentSource, ";else{", elseIndex) !== elseIndex + ) { + elseIndex = currentSource.indexOf(";else{", elseIndex + 1); + } + if (elseIndex < 0 || elseIndex >= containing.closeIndex) continue; + const remoteBranch = currentSource.slice(branchStart, elseIndex + 1); + const safePostIndex = currentSource.indexOf( + ".safePost(", + branchStart + remotePrefix.length, + ); + if ( + safePostIndex < 0 || + safePostIndex >= elseIndex || + findExecutableJavaScriptSubstring(currentSource, ".safePost(", safePostIndex) !== safePostIndex + ) { + continue; + } + + const localStart = elseIndex + ";else{".length; + const localMatch = currentSource.slice(localStart).match( + /^let ([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*)\(([^()]*)\);await ([A-Za-z_$][\w$]*)\(([^()]*)\)\.sendRequest\(`config\/batchWrite`,/, + ); + if (localMatch == null) continue; + const readResultVar = localMatch[1]; + const optionsStart = localStart + localMatch[0].length; if (currentSource[optionsStart] !== "{") continue; const optionsEnd = findMatchingBrace(currentSource, optionsStart); - if (optionsEnd < optionsStart || currentSource[optionsEnd + 1] !== ")") continue; + if ( + optionsEnd < optionsStart || + currentSource.slice(optionsEnd + 1, optionsEnd + 9) !== ")}await " + ) { + continue; + } - const idVar = match[1]; - const enabledVar = match[2]; - const readResultVar = patched ? match[6] : match[5]; const options = currentSource.slice(optionsStart, optionsEnd + 1); const optionsPattern = new RegExp( - `^\\{hostId:[A-Za-z_$][\\w$]*,edits:[A-Za-z_$][\\w$]*\\(\\{pluginId:${escapeRegExp(idVar)},enabled:${escapeRegExp(enabledVar)}\\}\\),` + + `^\\{edits:[A-Za-z_$][\\w$]*\\(\\{pluginId:${escapeRegExp(idVar)},enabled:${escapeRegExp(enabledVar)}\\}\\),` + `filePath:${escapeRegExp(readResultVar)}\\?\\.filePath\\?\\?null,` + `expectedVersion:${escapeRegExp(readResultVar)}\\?\\.expectedVersion\\?\\?null,reloadUserConfig:!0\\}$`, ); if (!optionsPattern.test(options)) continue; results.push({ - argumentVar: match[4], - dispatchVar: patched ? match[5] : null, + argumentVar, + destructuringSuffix, + dispatchVar, enabledVar, - end: optionsEnd + 2, - extraBindings: match[3], + end: match.index + match[0].length, idVar, index: match.index, options, - readArguments: patched ? match[8] : match[7], - readFunctionVar: patched ? match[7] : match[6], + readArguments: localMatch[3], + readFunctionVar: localMatch[2], readResultVar, - writeFunctionVar: patched ? match[10] : match[9], - writeResultVar: patched ? match[9] : match[8], + writeFunctionVar: localMatch[4], }); } return results; @@ -329,14 +393,10 @@ function applyLinuxComputerUseDisableOrderingPatch(currentSource) { throw new Error("Required Linux Computer Use disable ordering patch failed: plugin config mutation unavailable or ambiguous"); } const match = matches[0]; - const writePrefix = match.writeResultVar == null - ? `;await ${match.writeFunctionVar}` - : `,${match.writeResultVar}=await ${match.writeFunctionVar}`; - const replacement = - `let{pluginId:${match.idVar},enabled:${match.enabledVar}${match.extraBindings}}=${match.argumentVar};` + + const revoke = `if(${match.idVar}===\`computer-use@openai-bundled\`&&${match.enabledVar}!==!0)await ${dispatchVar}.dispatchMessage(\`chatgpt-linux-computer-use-disable-requested\`,{});` + - `/*${LINUX_COMPUTER_USE_DISABLE_ORDERING_MARKER}*/let ${match.readResultVar}=await ${match.readFunctionVar}(${match.readArguments})${writePrefix}(\`batch-write-config-value\`,${match.options})`; - const result = currentSource.slice(0, match.index) + replacement + + `/*${LINUX_COMPUTER_USE_DISABLE_ORDERING_MARKER}*/`; + const result = currentSource.slice(0, match.end) + revoke + currentSource.slice(match.end); if (!hasLinuxComputerUseDisableOrderingContract(result)) { throw new Error( @@ -725,7 +785,7 @@ function findContainingFunction(source, targetIndex) { return containing; } -function applyComputerUseSettings26803Contract(currentSource) { +function applyCurrentComputerUseSettingsContract(currentSource) { const markerPattern = /let ([A-Za-z_$][\w$]*BundledMarketplaceDonor)=([A-Za-z_$][\w$]*)\.availablePlugins\.find\(e=>e\.marketplaceName===`openai-bundled`[\s\S]{0,600}?let ([A-Za-z_$][\w$]*OfficialPluginState)=\[\.\.\.\2\.availablePlugins,\.\.\.\(\2\.installedPlugins\?\?\[\]\)\]\.find\(e=>\1!=null&&e\.marketplaceName===`openai-bundled`&&e\.marketplacePath===\1\.marketplacePath&&e\.plugin\?\.id===`computer-use@openai-bundled`&&e\.plugin\?\.name===([A-Za-z_$][\w$]*)\);[\s\S]{0,500}?!\2\.availablePlugins\.some\(e=>e\.marketplaceName===`openai-bundled`&&e\.marketplacePath===\1\.marketplacePath&&e\.plugin\?\.id===`computer-use@openai-bundled`&&e\.plugin\?\.name===\4\)[\s\S]{0,800}?plugin:\{id:`computer-use@openai-bundled`,name:\4,installed:\3\?\.plugin\?\.installed===!0,enabled:\3\?\.plugin\?\.enabled===!0/; if (markerPattern.test(currentSource)) return currentSource; @@ -784,10 +844,6 @@ function applyComputerUseSettings26803Contract(currentSource) { return markerPattern.test(patchedSource) ? patchedSource : null; } -function applyCurrentComputerUseSettingsContract(currentSource) { - return applyComputerUseSettings26803Contract(currentSource); -} - function matchesLinuxComputerUseRendererAvailabilityContract(currentSource) { return applyCurrentComputerUseSettingsContract(currentSource) != null; } @@ -879,42 +935,86 @@ function applyLinuxComputerUseHostPlatformPatch(currentSource) { } function applyCurrentComputerUseInstallFlowContract(currentSource) { - if (currentSource.includes("plugin detail query requires pluginName")) { - const markerPattern = - /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!==`computer-use`,([A-Za-z_$][\w$]*);/; - if (markerPattern.test(currentSource)) { - return currentSource; - } + if ( + !currentSource.includes("plugin detail query requires pluginName") || + !currentSource.includes(".sendRequest(`plugin/read`,") + ) { + return null; + } - const needlePattern = - /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*);/g; - let changed = false; - const patchedSource = currentSource.replace( - needlePattern, - (match, gateVar, gateValueVar, nextVar, offset) => { - const lookback = currentSource.slice(Math.max(0, offset - 900), offset); - const nextSource = currentSource.slice(offset + match.length, offset + match.length + 1800); - const pluginNameVar = lookback.match(/pluginName:([A-Za-z_$][\w$]*)/)?.[1]; - if ( - pluginNameVar == null || - !new RegExp( - String.raw`&&\(!${gateVar}\|\|[A-Za-z_$][\w$]*\.available\)`, - ).test(nextSource) || - !nextSource.includes("`read-plugin`") - ) { - return match; - } - changed = true; - return `let ${gateVar}=${gateValueVar}&&${pluginNameVar}!==\`computer-use\`,${nextVar};`; - }, + const gatePattern = + /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)!=null&&([A-Za-z_$][\w$]*)\(\2\),([A-Za-z_$][\w$]*)\[\d+\]=\2,\4\[\d+\]=\1\);(let ([A-Za-z_$][\w$]*)=\1(?:&&([A-Za-z_$][\w$]*)!==`computer-use`)?,([A-Za-z_$][\w$]*);)/g; + const candidates = []; + for (const match of executableRegexMatches(currentSource, gatePattern)) { + if (match.index == null) continue; + const containing = findContainingFunction(currentSource, match.index); + if (containing == null) continue; + const paramsVar = containing.match[2].trim(); + if (!/^[A-Za-z_$][\w$]*$/.test(paramsVar)) continue; + const destructuringMatches = executableRegexMatches( + containing.text, + new RegExp( + String.raw`\{[^{}]*pluginName:([A-Za-z_$][\w$]*)[^{}]*\}=${paramsVar}`, + ), ); - - if (changed && markerPattern.test(patchedSource)) { - return patchedSource; + const pluginNameVar = destructuringMatches.length === 1 + ? destructuringMatches[0][1] + : null; + if (pluginNameVar == null || pluginNameVar !== match[2]) continue; + const predicateVar = match[3]; + const predicateMatches = executableRegexMatches( + currentSource, + new RegExp( + String.raw`function ${escapeRegExp(predicateVar)}\(([A-Za-z_$][\w$]*)\)\{return [A-Za-z_$][\w$]*\(\1\)===([A-Za-z_$][\w$]*)\}`, + ), + ); + const predicate = predicateMatches.length === 1 ? predicateMatches[0] : null; + const pluginNameAssignments = predicate == null + ? [] + : executableRegexMatches( + currentSource, + new RegExp(`${escapeRegExp(predicate[2])}=\`computer-use\``), + ); + const availabilityMatches = executableRegexMatches( + containing.text, + new RegExp( + String.raw`&&\(!${escapeRegExp(match[6])}\|\|[A-Za-z_$][\w$]*\.available\)`, + ), + ); + const detailInvocationMatches = executableRegexMatches( + containing.text, + new RegExp( + String.raw`[A-Za-z_$][\w$]*\(\{scope:[A-Za-z_$][\w$]*,accountId:null,hostId:[A-Za-z_$][\w$]*,marketplacePath:[A-Za-z_$][\w$]*,pluginName:${escapeRegExp(pluginNameVar)},queryClient:[A-Za-z_$][\w$]*,remoteMarketplaceName:[A-Za-z_$][\w$]*\}\)`, + ), + ); + if ( + predicate == null || + pluginNameAssignments.length !== 1 || + availabilityMatches.length !== 1 || + detailInvocationMatches.length !== 1 || + (match[7] != null && match[7] !== pluginNameVar) + ) { + continue; } + candidates.push({ + gateValueVar: match[1], + gateVar: match[6], + index: match.index + match[0].length - match[5].length, + length: match[5].length, + nextVar: match[8], + patched: match[7] === pluginNameVar, + predicateVar, + pluginNameVar, + }); } - return null; + if (candidates.length !== 1) return null; + const candidate = candidates[0]; + if (candidate.patched) return currentSource; + const replacement = + `let ${candidate.gateVar}=${candidate.gateValueVar}&&${candidate.pluginNameVar}!==\`computer-use\`,${candidate.nextVar};`; + return currentSource.slice(0, candidate.index) + replacement + + currentSource.slice(candidate.index + candidate.length); } function matchesLinuxComputerUseInstallFlowContract(currentSource) { diff --git a/scripts/patches/impl/main-process/browser.js b/scripts/patches/impl/main-process/browser.js index 50f0720d7..5dc83e251 100644 --- a/scripts/patches/impl/main-process/browser.js +++ b/scripts/patches/impl/main-process/browser.js @@ -418,6 +418,230 @@ function applyBrowserUseNodeReplApprovalPatch(currentSource) { return patchedSource; } +const identifierPattern = "[A-Za-z_$][\\w$]*"; + +function executableStringBinding(source, value) { + const matches = executableRegexMatches( + source, + new RegExp(`(${identifierPattern})=\\\`${escapeRegExp(value)}\\\``, "g"), + ); + return matches.length === 1 ? matches[0][1] : null; +} + +function executableFunctionMatches(source, pattern) { + return executableRegexMatches(source, pattern).map((match) => { + const openIndex = match.index + match[0].length - 1; + return { closeIndex: findMatchingBrace(source, openIndex), match, openIndex }; + }).filter(({ closeIndex, openIndex }) => closeIndex > openIndex); +} + +function executableMatchesInside(source, pattern, range) { + const bodyStart = range.openIndex + 1; + const body = source.slice(bodyStart, range.closeIndex); + return executableRegexMatches(body, pattern).map((match) => { + match.index += bodyStart; + return match; + }); +} + +function enclosingObjectAssignment(source, index, range) { + const assignments = executableMatchesInside( + source, + new RegExp(`(?:let |,)(${identifierPattern})=\\{`, "g"), + range, + ).map((match) => { + const openIndex = match.index + match[0].length - 1; + return { + closeIndex: findMatchingBrace(source, openIndex), + match, + openIndex, + }; + }).filter(({ closeIndex, openIndex }) => openIndex < index && closeIndex >= index); + return assignments.length === 1 ? assignments[0] : null; +} + +function browserUseSecurityContextProducerContractFailure(source) { + const trustedCodePathsKey = executableStringBinding( + source, + "NODE_REPL_TRUSTED_CODE_PATHS", + ); + const trustedServicesKey = executableStringBinding(source, "NODE_REPL_TRUSTED_SERVICES"); + const nodeReplServerKey = executableStringBinding(source, "node_repl"); + const backendsKey = executableStringBinding(source, "BROWSER_USE_AVAILABLE_BACKENDS"); + const buildFlavorKey = executableStringBinding( + source, + "BROWSER_USE_CODEX_APP_BUILD_FLAVOR", + ); + const appVersionKey = executableStringBinding(source, "BROWSER_USE_CODEX_APP_VERSION"); + const securityModeKey = executableStringBinding(source, "BROWSER_USE_SECURITY_MODE"); + if ( + [ + trustedCodePathsKey, + trustedServicesKey, + nodeReplServerKey, + backendsKey, + buildFlavorKey, + appVersionKey, + securityModeKey, + ].some((value) => value == null) + ) return "environment key bindings"; + + const runtimeBuilders = executableFunctionMatches( + source, + new RegExp( + `function (${identifierPattern})\\(\\{codexCliPath:(${identifierPattern}),codexHome:(${identifierPattern}),envVars:(${identifierPattern})=\\[\\],extraEnv:(${identifierPattern}),nodeModuleDirs:(${identifierPattern})=\\\`\\\`,nodePath:(${identifierPattern}),nodeReplPath:(${identifierPattern}),platform:(${identifierPattern}),requestMeta:(${identifierPattern}),sentryUserId:(${identifierPattern}),traceMeta:(${identifierPattern})=!1,shouldUseWslPaths:(${identifierPattern})\\}\\)\\{`, + "g", + ), + ); + if (runtimeBuilders.length !== 1) return "runtime builder signature"; + const runtimeBuilder = runtimeBuilders[0]; + const [ + , + runtimeBuilderName, + codexCliArg, + codexHomeArg, + envVarsArg, + extraEnvArg, + moduleDirsArg, + nodePathArg, + nodeReplPathArg, + platformArg, + ] = runtimeBuilder.match; + const trustedPathMatches = executableMatchesInside( + source, + new RegExp( + `\\[${escapeRegExp(trustedCodePathsKey)}\\]:(${identifierPattern})\\(\\[${escapeRegExp(codexHomeArg)},${escapeRegExp(moduleDirsArg)}\\],${escapeRegExp(platformArg)}\\),CODEX_HOME:${escapeRegExp(codexHomeArg)}(?=[,}])`, + "g", + ), + runtimeBuilder, + ); + if (trustedPathMatches.length !== 1) return "trusted code paths assignment"; + const runtimeEnvAssignment = enclosingObjectAssignment( + source, + trustedPathMatches[0].index, + runtimeBuilder, + ); + if (runtimeEnvAssignment == null) return "runtime environment assignment"; + const runtimeEnvVar = runtimeEnvAssignment.match[1]; + const extraEnvCopies = executableMatchesInside( + source, + new RegExp( + `Object\\.assign\\(${escapeRegExp(runtimeEnvVar)},${identifierPattern}\\(${escapeRegExp(extraEnvArg)}\\)\\)`, + "g", + ), + runtimeBuilder, + ); + const nodeReplConfigs = executableMatchesInside( + source, + new RegExp( + `\\{\\[\\\`mcp_servers\\.\\$\\{${escapeRegExp(nodeReplServerKey)}\\}\\\`\\]:\\{args:\\[\\],command:${escapeRegExp(nodeReplPathArg)},env:${escapeRegExp(runtimeEnvVar)},`, + "g", + ), + runtimeBuilder, + ); + if (extraEnvCopies.length !== 1) return "runtime extra environment copy"; + if (nodeReplConfigs.length !== 1) return "node_repl runtime configuration"; + + const producers = executableFunctionMatches( + source, + new RegExp( + `function (${identifierPattern})\\(\\{appVersion:(${identifierPattern}),availableBrowserUseBackends:(${identifierPattern}),computerUse:(${identifierPattern}),enforceModelCheck:(${identifierPattern}),computerUseNativePipePath:${identifierPattern},computerUsePaths:${identifierPattern},hostServicesPipePath:${identifierPattern},includePrivateProcessEnv:${identifierPattern},runtimePaths:(${identifierPattern}),sentryUserId:${identifierPattern},shouldUseWslPaths:(${identifierPattern})\\}\\)\\{`, + "g", + ), + ); + if (producers.length !== 1) return "browser producer signature"; + const producer = producers[0]; + const [, , appVersionArg, backendsArg, computerUseArg, , runtimePathsArg, wslArg] = + producer.match; + const serviceMatches = executableMatchesInside( + source, + new RegExp( + `let (${identifierPattern})=${identifierPattern}\\(${escapeRegExp(runtimePathsArg)}\\.nodeModuleDirs,${escapeRegExp(runtimePathsArg)}\\.platform\\),(${identifierPattern})=${identifierPattern}(?:\\.${identifierPattern})*\\(\\),(${identifierPattern})=${identifierPattern}(?:\\.${identifierPattern})*\\.resolve\\(\\),(${identifierPattern})=${escapeRegExp(backendsArg)}\\.length===0\\?void 0:\\\`\\$\\{(${identifierPattern})\\.${identifierPattern}\\(\\{codexHome:\\2,localVersion:${escapeRegExp(appVersionArg)},marketplaceName:\\5\\.${identifierPattern}\\(\\3\\),pluginName:\\5\\.${identifierPattern}\\}\\)\\}\\/scripts\\/browser-service\\.mjs\\\``, + "g", + ), + producer, + ); + if (serviceMatches.length !== 1) return "browser service path"; + const [, moduleDirsVar, codexHomeVar, buildFlavorVar, serviceVar] = serviceMatches[0]; + + const boundaryParts = [ + new RegExp(`\\[${escapeRegExp(backendsKey)}\\]:${escapeRegExp(backendsArg)}\\.join\\(\\\`,\\\`\\)`, "g"), + new RegExp(`\\[${escapeRegExp(buildFlavorKey)}\\]:${escapeRegExp(buildFlavorVar)}(?=[,}])`, "g"), + new RegExp(`\\[${escapeRegExp(appVersionKey)}\\]:${escapeRegExp(appVersionArg)}(?=[,}])`, "g"), + new RegExp( + `\\[${escapeRegExp(trustedServicesKey)}\\]:${escapeRegExp(serviceVar)}==null&&!${escapeRegExp(computerUseArg)}\\?void 0:JSON\\.stringify\\(\\{\\.\\.\\.${escapeRegExp(serviceVar)}==null\\?\\{\\}:\\{browser:${escapeRegExp(serviceVar)}\\},\\.\\.\\.${escapeRegExp(computerUseArg)}\\?\\{sky:\\\`@oai/sky/service\\\`\\}:\\{\\}\\}\\)`, + "g", + ), + ].map((pattern) => executableMatchesInside(source, pattern, producer)); + if (boundaryParts.some((matches) => matches.length !== 1)) { + return "boundary environment fields"; + } + const extraEnvAssignment = enclosingObjectAssignment( + source, + boundaryParts[0][0].index, + producer, + ); + if (extraEnvAssignment == null) return "producer extra environment assignment"; + const extraEnvVar = extraEnvAssignment.match[1]; + if ( + boundaryParts.some( + ([match]) => match.index < extraEnvAssignment.openIndex || + match.index > extraEnvAssignment.closeIndex, + ) + ) return "boundary fields scoped to producer extra environment"; + + const devForwarding = executableMatchesInside( + source, + new RegExp( + `\\.\\.\\.(${identifierPattern})\\((${identifierPattern})\\),\\.\\.\\.${escapeRegExp(buildFlavorVar)}===(${identifierPattern}(?:\\.${identifierPattern})*)\\.Dev\\?\\1\\((${identifierPattern})\\):\\{\\}`, + "g", + ), + producer, + ).filter( + (match) => match.index > extraEnvAssignment.openIndex && + match.index < extraEnvAssignment.closeIndex, + ); + if (devForwarding.length !== 1) return "development environment forwarding"; + const [, , commonEnvKeys, , devEnvKeys] = devForwarding[0]; + if (commonEnvKeys === devEnvKeys) { + return "distinct common and development environment keys"; + } + + const securityModeArrays = executableRegexMatches( + source, + new RegExp( + `(?:var |,)(${identifierPattern})=\\[((?:${identifierPattern},)*)${escapeRegExp(securityModeKey)}((?:,${identifierPattern})*)\\]`, + "g", + ), + ); + if (securityModeArrays.length !== 1 || securityModeArrays[0][1] !== devEnvKeys) { + return "security mode restricted to development"; + } + + const runtimeCalls = executableMatchesInside( + source, + new RegExp( + `return ${escapeRegExp(runtimeBuilderName)}\\(\\{codexCliPath:${escapeRegExp(runtimePathsArg)}\\.codexCliPath,codexHome:${escapeRegExp(codexHomeVar)},envVars:${identifierPattern},extraEnv:${escapeRegExp(extraEnvVar)},nodeModuleDirs:${escapeRegExp(moduleDirsVar)},nodePath:${escapeRegExp(runtimePathsArg)}\\.nodePath,nodeReplPath:${escapeRegExp(wslArg)}\\?${identifierPattern}(?:\\.${identifierPattern})*\\(${escapeRegExp(runtimePathsArg)}\\.nodeReplPath\\):${escapeRegExp(runtimePathsArg)}\\.nodeReplPath,platform:${escapeRegExp(runtimePathsArg)}\\.platform,requestMeta:${identifierPattern},sentryUserId:${identifierPattern},traceMeta:${identifierPattern},shouldUseWslPaths:${escapeRegExp(wslArg)}\\}\\)`, + "g", + ), + producer, + ); + return runtimeCalls.length === 1 ? null : "runtime builder call"; +} + +// This required patch intentionally leaves source unchanged. It is a fail-closed +// assertion of the current Browser Use node_repl trusted-service producer contract. +function applyBrowserUseNodeReplSecurityContextPatch(currentSource) { + const failure = browserUseSecurityContextProducerContractFailure(currentSource); + if (failure != null) { + throw new Error( + "Required Browser Use node_repl trusted-service producer contract " + + `was not found exactly once: ${failure}`, + ); + } + return currentSource; +} + // The trusted-hash setup and node_repl config can live in different build chunks. // Scan every chunk carrying either marker so each patch reaches its current host. function applyBrowserUseNodeReplApprovalAssets(extractedDir) { @@ -620,6 +844,7 @@ function applyLinuxExternalOpenEnvPatch(currentSource) { module.exports = { applyBrowserUseNodeReplApprovalPatch, applyBrowserUseNodeReplApprovalAssets, + applyBrowserUseNodeReplSecurityContextPatch, applyLinuxBundledPluginCopyPermissionsPatch, applyLinuxBundledPluginReconcileStaleSnapshotPatch, applyLinuxExternalOpenEnvPatch, diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 0cb1cfa67..454a44090 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -257,22 +257,16 @@ function applyLinuxExplicitIpcQuitPatch(currentSource) { const quitMarkerExpression = linuxExplicitQuitExpression(); - const quitAppNeedle = "if(o.type===`quit-app`){n.app.quit();return}"; - const quitAppPatch = `if(o.type===\`quit-app\`){${quitMarkerExpression}n.app.quit();return}`; const quitAppRegex = - /if\(([A-Za-z_$][\w$]*)\.type===`quit-app`\)\{([A-Za-z_$][\w$]*)\.app\.quit\(\);return\}/g; + /if\(([A-Za-z_$][\w$]*)\.type===`quit-app`\)\{\1\.relaunch===!0&&\(([A-Za-z_$][\w$]*)\.quitState\?\.allowQuitTemporarily\(\),([A-Za-z_$][\w$]*)\.app\.relaunch\(\)\),\3\.app\.quit\(\);return\}/g; const patchedQuitAppRegex = - /if\([A-Za-z_$][\w$]*\.type===`quit-app`\)\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),[A-Za-z_$][\w$]*\.app\.quit\(\);return\}/; + /if\(([A-Za-z_$][\w$]*)\.type===`quit-app`\)\{\1\.relaunch===!0&&\(([A-Za-z_$][\w$]*)\.quitState\?\.allowQuitTemporarily\(\),([A-Za-z_$][\w$]*)\.app\.relaunch\(\)\),typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),\3\.app\.quit\(\);return\}/; let patchedAny = false; - if (patchedSource.includes(quitAppNeedle)) { - patchedAny = true; - patchedSource = patchedSource.split(quitAppNeedle).join(quitAppPatch); - } patchedSource = patchedSource.replace( quitAppRegex, - (_match, messageVar, electronVar) => { + (_match, messageVar, contextVar, electronVar) => { patchedAny = true; - return `if(${messageVar}.type===\`quit-app\`){${quitMarkerExpression}${electronVar}.app.quit();return}`; + return `if(${messageVar}.type===\`quit-app\`){${messageVar}.relaunch===!0&&(${contextVar}.quitState?.allowQuitTemporarily(),${electronVar}.app.relaunch()),${quitMarkerExpression}${electronVar}.app.quit();return}`; }, ); if (!patchedAny && !patchedQuitAppRegex.test(patchedSource) && patchedSource.includes("type===`quit-app`")) { diff --git a/scripts/patches/impl/main-process/tray.js b/scripts/patches/impl/main-process/tray.js index ffd1d6778..39c7886d8 100644 --- a/scripts/patches/impl/main-process/tray.js +++ b/scripts/patches/impl/main-process/tray.js @@ -1,6 +1,11 @@ "use strict"; -const { requireName } = require("../../lib/minified-js.js"); +const { + escapeRegExp, + findExecutableJavaScriptSubstring, + findMatchingBrace, + requireName, +} = require("../../lib/minified-js.js"); function findMatchingParenthesis(source, openIndex) { let depth = 0; @@ -65,6 +70,16 @@ function findTrayConstructor(source) { if (shape.retained && end !== closeIndex + 2) { continue; } + const constructorSource = source.slice(match.index, end); + if ( + findExecutableJavaScriptSubstring( + source, + constructorSource, + match.index, + ) !== match.index + ) { + continue; + } candidates.push({ start: match.index, end, @@ -79,6 +94,120 @@ function findTrayConstructor(source) { return candidates.length === 1 ? candidates[0] : null; } +function findContainingTrayFactory(source, constructorMatch) { + const identifier = "[A-Za-z_$][\\w$]*"; + const factoryPattern = new RegExp( + `async function (?${identifier})\\([^)]{0,512}\\)\\{`, + "g", + ); + const candidates = []; + + for (const factoryMatch of source.matchAll(factoryPattern)) { + if ( + findExecutableJavaScriptSubstring( + source, + factoryMatch[0], + factoryMatch.index, + ) !== factoryMatch.index + ) { + continue; + } + const openIndex = factoryMatch.index + factoryMatch[0].length - 1; + const closeIndex = findMatchingBrace(source, openIndex); + if ( + closeIndex !== -1 && + factoryMatch.index <= constructorMatch.start && + constructorMatch.end <= closeIndex + ) { + candidates.push({ + start: factoryMatch.index, + end: closeIndex + 1, + factoryName: factoryMatch.groups.factory, + }); + } + } + + return candidates.length === 1 ? candidates[0] : null; +} + +function findTrayWrapperClass(source, trayFactory, trayVar) { + const identifier = "[A-Za-z_$][\\w$]*"; + const factorySource = source.slice(trayFactory.start, trayFactory.end); + const factoryPattern = new RegExp( + `let (?${identifier})=new (?${identifier})\\(${escapeRegExp(trayVar)}(?:,[^;]{1,2048})?\\);[\\s\\S]{0,192}?(?\\k\\.waitForReady\\(\\))`, + "g", + ); + const candidates = []; + + for (const factoryMatch of factorySource.matchAll(factoryPattern)) { + const factoryMatchIndex = trayFactory.start + factoryMatch.index; + const factoryEnd = factoryMatch[0].indexOf(";") + 1; + const wrapperFactorySource = factoryMatch[0].slice(0, factoryEnd); + const readinessIndex = + factoryMatchIndex + factoryMatch[0].lastIndexOf(factoryMatch.groups.readiness); + if ( + findExecutableJavaScriptSubstring( + source, + wrapperFactorySource, + factoryMatchIndex, + ) !== factoryMatchIndex || + findExecutableJavaScriptSubstring( + source, + factoryMatch.groups.readiness, + readinessIndex, + ) !== readinessIndex + ) { + continue; + } + const wrapperClass = factoryMatch.groups.wrapperClass; + const classPattern = new RegExp( + `(?:var|let|const) ${wrapperClass}=class\\{`, + "g", + ); + const classMatches = [...source.matchAll(classPattern)].filter( + (classMatch) => + findExecutableJavaScriptSubstring( + source, + classMatch[0], + classMatch.index, + ) === classMatch.index, + ); + if (classMatches.length !== 1) continue; + const classMatch = classMatches[0]; + const openIndex = classMatch.index + classMatch[0].length - 1; + const closeIndex = findMatchingBrace(source, openIndex); + if (closeIndex === -1) continue; + candidates.push({ + start: classMatch.index, + end: closeIndex + 1, + wrapper: factoryMatch.groups.wrapper, + wrapperClass, + }); + } + + return candidates.length === 1 ? candidates[0] : null; +} + +function executableMatches(source, pattern) { + return [...source.matchAll(pattern)].filter( + (match) => + findExecutableJavaScriptSubstring(source, match[0], match.index) === + match.index, + ); +} + +function executableSubstringIndexes(source, needle) { + const indexes = []; + let fromIndex = 0; + while (fromIndex < source.length) { + const index = findExecutableJavaScriptSubstring(source, needle, fromIndex); + if (index === -1) break; + indexes.push(index); + fromIndex = index + Math.max(needle.length, 1); + } + return indexes; +} + function applyLinuxTrayPatch(currentSource, iconPathExpression) { let patchedSource = currentSource; @@ -99,29 +228,66 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { ); } - const providerPath = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*"; - const trayReadinessWrapperPattern = new RegExp( - `isReady\\(\\)\\{return (${providerPath})\\(this\\.tray\\)\\}` + - `waitForReady\\(\\)\\{return (${providerPath})\\(this\\.tray\\)\\}`, + const delegatedReadinessPattern = + /isReady\(\)\{return ([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(this\.tray\)\}waitForReady\(\)\{return ([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(this\.tray\)\}/g; + const compatibleDelegatedReadinessPattern = + /isReady\(\)\{if\(process\.platform!==`linux`\)return ([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(this\.tray\);let ([A-Za-z_$][\w$]*)=this\.tray;return typeof \2\.isReady==`function`\?\2\.isReady\(\):!0\}async waitForReady\(\)\{if\(process\.platform!==`linux`\)return ([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*)\(this\.tray\);let ([A-Za-z_$][\w$]*)=this\.tray;if\(typeof \4\.whenReady!=`function`\)return!0;try\{return await \4\.whenReady\(\),!0\}catch\{return!1\}\}/g; + const readinessConstructor = findTrayConstructor(patchedSource); + const readinessFactory = readinessConstructor == null + ? null + : findContainingTrayFactory(patchedSource, readinessConstructor); + if (readinessConstructor == null || readinessFactory == null) { + console.warn("WARN: Could not find current Linux tray factory — skipping Linux tray retention patch"); + return currentSource; + } + const trayWrapperClass = findTrayWrapperClass( + patchedSource, + readinessFactory, + readinessConstructor.trayVar, ); - const compatibleTrayReadinessWrapperPattern = new RegExp( - `isReady\\(\\)\\{return process\\.platform===\`linux\`&&typeof this\\.tray\\.isReady!=\`function\`\\?!0:${providerPath}\\(this\\.tray\\)\\}` + - `waitForReady\\(\\)\\{return process\\.platform===\`linux\`&&typeof this\\.tray\\.whenReady!=\`function\`\\?Promise\\.resolve\\(!0\\):${providerPath}\\(this\\.tray\\)\\}`, + if (trayWrapperClass == null) { + console.warn("WARN: Could not find the current tray wrapper class — skipping Linux tray compatibility patch"); + return currentSource; + } + let trayWrapperSource = patchedSource.slice( + trayWrapperClass.start, + trayWrapperClass.end, ); - if (!compatibleTrayReadinessWrapperPattern.test(patchedSource)) { - const readinessMatch = patchedSource.match(trayReadinessWrapperPattern); - if (readinessMatch == null) { - console.warn("WARN: Could not find current Linux tray readiness wrappers — skipping Linux tray compatibility patch"); - return currentSource; - } - const [, isReadyProvider, waitForReadyProvider] = readinessMatch; - patchedSource = patchedSource.replace( - trayReadinessWrapperPattern, + const delegatedReadinessMatches = executableMatches( + trayWrapperSource, + delegatedReadinessPattern, + ); + const compatibleDelegatedReadinessMatches = executableMatches( + trayWrapperSource, + compatibleDelegatedReadinessPattern, + ); + if ( + delegatedReadinessMatches.length + compatibleDelegatedReadinessMatches.length !== + 1 + ) { + console.warn("WARN: Could not find one unambiguous current Linux tray readiness delegate — skipping Linux tray compatibility patch"); + return currentSource; + } + if (delegatedReadinessMatches.length === 1) { + const delegatedReadinessMatch = delegatedReadinessMatches[0]; + const [, isReadyDelegate, waitForReadyDelegate] = delegatedReadinessMatch; + trayWrapperSource = trayWrapperSource.replace( + delegatedReadinessPattern, () => - `isReady(){return process.platform===\`linux\`&&typeof this.tray.isReady!=\`function\`?!0:${isReadyProvider}(this.tray)}` + - `waitForReady(){return process.platform===\`linux\`&&typeof this.tray.whenReady!=\`function\`?Promise.resolve(!0):${waitForReadyProvider}(this.tray)}`, + `isReady(){if(process.platform!==\`linux\`)return ${isReadyDelegate}(this.tray);let e=this.tray;return typeof e.isReady==\`function\`?e.isReady():!0}async waitForReady(){if(process.platform!==\`linux\`)return ${waitForReadyDelegate}(this.tray);let e=this.tray;if(typeof e.whenReady!=\`function\`)return!0;try{return await e.whenReady(),!0}catch{return!1}}`, ); } + if ( + executableMatches(trayWrapperSource, compatibleDelegatedReadinessPattern) + .length !== 1 + ) { + console.warn("WARN: Could not verify the current Linux tray readiness delegate — skipping Linux tray compatibility patch"); + return currentSource; + } + patchedSource = + patchedSource.slice(0, trayWrapperClass.start) + + trayWrapperSource + + patchedSource.slice(trayWrapperClass.end); if ( iconPathExpression != null && @@ -143,8 +309,21 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { } const constructorMatch = findTrayConstructor(patchedSource); + const constructorFactory = constructorMatch == null + ? null + : findContainingTrayFactory(patchedSource, constructorMatch); + const retainedTrayWrapper = + constructorMatch == null || constructorFactory == null + ? null + : findTrayWrapperClass( + patchedSource, + constructorFactory, + constructorMatch.trayVar, + ); if ( constructorMatch == null || + constructorFactory == null || + retainedTrayWrapper == null || !patchedSource.includes("if(process.platform===`linux`){") || !patchedSource.includes("updatePersistentTrayMenu(){process.platform===`linux`") ) { @@ -155,6 +334,7 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { const { trayVar, electronVar, constructorArgs } = constructorMatch; const retainedConstructor = `${trayVar}=chatgptLinuxRegisterTray(new ${electronVar}.Tray(${constructorArgs}))`; + let retainedConstructorIndex = constructorMatch.start; if (!constructorMatch.retained) { patchedSource = patchedSource.slice(0, constructorMatch.start) + @@ -162,19 +342,71 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { patchedSource.slice(constructorMatch.end); } - if (!patchedSource.includes("chatgptLinuxRegisterTray=e=>")) { - const constructorIndex = patchedSource.indexOf(retainedConstructor); - const factoryIndex = patchedSource.lastIndexOf("async function ", constructorIndex); - if (constructorIndex === -1 || factoryIndex === -1) { - console.warn("WARN: Could not find current Linux tray helper insertion point — skipping Linux tray retention patch"); - return currentSource; - } + const factoryIndex = constructorFactory.start; + let helperIndexes = executableSubstringIndexes( + patchedSource, + "chatgptLinuxRegisterTray=e=>", + ); + if ( + factoryIndex === -1 || + helperIndexes.length > 1 || + (helperIndexes.length === 1 && helperIndexes[0] >= factoryIndex) + ) { + console.warn("WARN: Could not find current Linux tray helper insertion point — skipping Linux tray retention patch"); + return currentSource; + } + if (helperIndexes.length === 0) { const retentionHelper = "let chatgptLinuxTray=null,chatgptLinuxRegisterTray=e=>(chatgptLinuxTray=e,e);"; patchedSource = patchedSource.slice(0, factoryIndex) + retentionHelper + patchedSource.slice(factoryIndex); + retainedConstructorIndex += retentionHelper.length; + } + + helperIndexes = executableSubstringIndexes( + patchedSource, + "chatgptLinuxRegisterTray=e=>", + ); + const retainedConstructorIndexes = executableSubstringIndexes( + patchedSource, + retainedConstructor, + ); + const finalConstructor = findTrayConstructor(patchedSource); + const finalTrayFactory = finalConstructor == null + ? null + : findContainingTrayFactory(patchedSource, finalConstructor); + const finalTrayWrapper = finalConstructor == null || finalTrayFactory == null + ? null + : findTrayWrapperClass( + patchedSource, + finalTrayFactory, + finalConstructor.trayVar, + ); + const finalTrayWrapperSource = finalTrayWrapper == null + ? "" + : patchedSource.slice(finalTrayWrapper.start, finalTrayWrapper.end); + if ( + helperIndexes.length !== 1 || + finalConstructor == null || + finalTrayFactory == null || + finalTrayWrapper == null || + helperIndexes[0] >= finalTrayFactory.start || + retainedConstructorIndexes.length !== 1 || + retainedConstructorIndexes[0] !== retainedConstructorIndex || + finalConstructor.trayVar !== trayVar || + finalConstructor.electronVar !== electronVar || + finalTrayFactory.factoryName !== constructorFactory.factoryName || + finalTrayWrapper.wrapper !== trayWrapperClass.wrapper || + finalTrayWrapper.wrapperClass !== trayWrapperClass.wrapperClass || + executableMatches( + finalTrayWrapperSource, + compatibleDelegatedReadinessPattern, + ).length !== 1 + ) { + console.warn("WARN: Could not verify current Linux tray retention helper — skipping Linux tray retention patch"); + return currentSource; } return patchedSource; diff --git a/scripts/patches/lib/minified-js.js b/scripts/patches/lib/minified-js.js index 94ea3b14d..40a56e6f3 100644 --- a/scripts/patches/lib/minified-js.js +++ b/scripts/patches/lib/minified-js.js @@ -132,6 +132,7 @@ function findMatchingBrace(source, openIndex) { function findExecutableJavaScriptSubstring(source, needle, fromIndex = 0) { let quote = null; let escaped = false; + const templateExpressionDepths = []; let canStartRegex = true; let pendingControlParen = false; let pendingBreakOrContinue = false; @@ -145,6 +146,11 @@ function findExecutableJavaScriptSubstring(source, needle, fromIndex = 0) { escaped = false; } else if (char === "\\") { escaped = true; + } else if (quote === "`" && char === "$" && next === "{") { + quote = null; + templateExpressionDepths.push(1); + canStartRegex = true; + index += 1; } else if (char === quote) { quote = null; canStartRegex = false; @@ -259,6 +265,23 @@ function findExecutableJavaScriptSubstring(source, needle, fromIndex = 0) { canStartRegex = true; continue; } + if (char === "{" && templateExpressionDepths.length > 0) { + templateExpressionDepths[templateExpressionDepths.length - 1] += 1; + canStartRegex = true; + continue; + } + if (char === "}" && templateExpressionDepths.length > 0) { + const expressionIndex = templateExpressionDepths.length - 1; + templateExpressionDepths[expressionIndex] -= 1; + if (templateExpressionDepths[expressionIndex] === 0) { + templateExpressionDepths.pop(); + quote = "`"; + canStartRegex = false; + } else { + canStartRegex = true; + } + continue; + } pendingControlParen = false; pendingBreakOrContinue = false; pendingBreakOrContinueLabel = false; diff --git a/tests/launcher_warm_start_recovery.sh b/tests/launcher_warm_start_recovery.sh index d3904d3f8..4a2e16465 100755 --- a/tests/launcher_warm_start_recovery.sh +++ b/tests/launcher_warm_start_recovery.sh @@ -9,11 +9,14 @@ HOME_DIR="$TMP_DIR/home" RUNTIME_DIR="$TMP_DIR/runtime" STATE_DIR="$HOME_DIR/.local/state/chatgpt" SOCKET_PATH="$RUNTIME_DIR/chatgpt/launch-action.sock" +COMPUTER_USE_AUTHORITY_SOCKET="$RUNTIME_DIR/chatgpt/computer-use-authority.sock" +COMPUTER_USE_CURSOR_SOCKET="$RUNTIME_DIR/chatgpt/computer-use-cursor.sock" FIRST_LOG="$TMP_DIR/first-launch.log" SECOND_LOG="$TMP_DIR/second-launch.log" APP_LOG="$HOME_DIR/.cache/chatgpt/launcher.log" LAUNCHER_PID="" SOCKET_PID="" +COMPUTER_USE_SOCKET_PID="" HOOK_PID="" cleanup() { @@ -24,6 +27,7 @@ cleanup() { fi [ -z "$LAUNCHER_PID" ] || kill "$LAUNCHER_PID" 2>/dev/null || true [ -z "$SOCKET_PID" ] || kill "$SOCKET_PID" 2>/dev/null || true + [ -z "$COMPUTER_USE_SOCKET_PID" ] || kill "$COMPUTER_USE_SOCKET_PID" 2>/dev/null || true [ -z "$HOOK_PID" ] || kill "$HOOK_PID" 2>/dev/null || true for cmdline in /proc/[0-9]*/cmdline; do [ -r "$cmdline" ] || continue @@ -61,6 +65,22 @@ wait_for() { fail "timed out waiting for $description" } +unix_socket_is_connectable() { + python3 - "$1" 2>/dev/null <<'PY' +import socket +import sys + +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(0.2) + client.connect(sys.argv[1]) + try: + client.sendall(b"\n") + client.recv(1) + except (BrokenPipeError, ConnectionResetError): + pass +PY +} + read_live_app_pid() { local pid pid="$(cat "$STATE_DIR/app.pid" 2>/dev/null || true)" @@ -96,6 +116,7 @@ mkdir -p \ "$HOME_DIR/.config/chatgpt" \ "$HOME_DIR" \ "$RUNTIME_DIR/chatgpt" +chmod 700 "$RUNTIME_DIR" "$RUNTIME_DIR/chatgpt" if [ "${CHATGPT_TEST_DISABLE_PIDFD:-0}" = "1" ]; then mkdir -p "$TMP_DIR/python-site" @@ -192,7 +213,52 @@ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: client.sendall(b"ok\n") PY SOCKET_PID=$! -wait_for "launch-action socket" test -S "$SOCKET_PATH" +wait_for "launch-action socket" unix_socket_is_connectable "$SOCKET_PATH" + +python3 - "$COMPUTER_USE_AUTHORITY_SOCKET" <<'PY' +import os +import socket +import sys + +for path in sys.argv[1:]: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(path) + os.chmod(path, 0o600) +PY + +if [ "${CHATGPT_TEST_UNTRUSTED_COMPUTER_USE_ENDPOINT:-0}" = "1" ]; then + printf '%s\n' "do not remove" > "$COMPUTER_USE_CURSOR_SOCKET" + chmod 600 "$COMPUTER_USE_CURSOR_SOCKET" +elif [ "${CHATGPT_TEST_LIVE_COMPUTER_USE_SOCKET:-0}" = "1" ]; then + python3 - "$COMPUTER_USE_CURSOR_SOCKET" <<'PY' & +import os +import socket +import sys + +path = sys.argv[1] +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(path) + os.chmod(path, 0o600) + server.listen() + while True: + client, _ = server.accept() + client.close() +PY + COMPUTER_USE_SOCKET_PID=$! + wait_for "live Computer Use cursor socket" \ + unix_socket_is_connectable "$COMPUTER_USE_CURSOR_SOCKET" +else + python3 - "$COMPUTER_USE_CURSOR_SOCKET" <<'PY' +import os +import socket +import sys + +path = sys.argv[1] +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(path) +os.chmod(path, 0o600) +PY +fi COMMON_ENV=( env -i @@ -248,6 +314,26 @@ if [ "${CHATGPT_TEST_NORMAL_LOCK_ONLY:-0}" = "1" ]; then if grep -q "launcher lock helper did not exit" "$FIRST_LOG"; then fail "normal launcher lock release should not require pidfd escalation" fi + [ ! -e "$COMPUTER_USE_AUTHORITY_SOCKET" ] \ + || fail "cold start did not remove the stale Computer Use authority socket" + grep -q "Removed stale Computer Use socket: computer-use-authority.sock" "$APP_LOG" \ + || fail "launcher did not report stale Computer Use authority socket recovery" + if [ "${CHATGPT_TEST_UNTRUSTED_COMPUTER_USE_ENDPOINT:-0}" = "1" ]; then + [ -f "$COMPUTER_USE_CURSOR_SOCKET" ] \ + || fail "cold start removed an untrusted Computer Use endpoint" + grep -q "preserving untrusted Computer Use endpoint: computer-use-cursor.sock" "$APP_LOG" \ + || fail "launcher did not report preserving the untrusted Computer Use endpoint" + elif [ "${CHATGPT_TEST_LIVE_COMPUTER_USE_SOCKET:-0}" = "1" ]; then + [ -S "$COMPUTER_USE_CURSOR_SOCKET" ] \ + || fail "cold start removed an active Computer Use cursor socket" + grep -q "Preserving active Computer Use socket: computer-use-cursor.sock" "$APP_LOG" \ + || fail "launcher did not report preserving the active Computer Use cursor socket" + else + [ ! -e "$COMPUTER_USE_CURSOR_SOCKET" ] \ + || fail "cold start did not remove the stale Computer Use cursor socket" + grep -q "Removed stale Computer Use socket: computer-use-cursor.sock" "$APP_LOG" \ + || fail "launcher did not report stale Computer Use cursor socket recovery" + fi kill "$FIRST_ELECTRON_PID" wait "$LAUNCHER_PID" LAUNCHER_PID="" diff --git a/tests/release_gate_public_contract.sh b/tests/release_gate_public_contract.sh index 917ab839a..df769c61f 100755 --- a/tests/release_gate_public_contract.sh +++ b/tests/release_gate_public_contract.sh @@ -23,6 +23,7 @@ CHATGPT_RELEASE_GATE_LIBRARY=1 PROVENANCE_HELPER="$REPO_DIR/scripts/lib/package-provenance.py" SHEBANG_HELPER="$REPO_DIR/scripts/lib/normalize-portable-shebangs.py" +NIX_NODE_REPL_ELF_HELPER="$REPO_DIR/scripts/lib/nix-node-repl-elf.sh" CLEAN_SOURCE_ROOT="$TEST_TMP/clean-source" CLEAN_SOURCE_INFO_ROOT="$TEST_TMP/clean-source-info" @@ -60,6 +61,85 @@ PY REPO_DIR="$ORIGINAL_REPO_DIR" unset CHATGPT_PACKAGE_NODE_SOURCE SOURCE_DATE_EPOCH +NODE_REPL_ELF_ROOT="$TEST_TMP/node-repl-elf" +mkdir -p "$NODE_REPL_ELF_ROOT" +cat > "$NODE_REPL_ELF_ROOT/fixture.c" <<'C' +#include + +int main(void) { + return puts("node-repl-elf-fixture") < 0; +} +C +cat > "$NODE_REPL_ELF_ROOT/no-needed.c" <<'C' +int node_repl_marker; +C +cc "$NODE_REPL_ELF_ROOT/fixture.c" -o "$NODE_REPL_ELF_ROOT/dynamic" +cc -static-pie "$NODE_REPL_ELF_ROOT/fixture.c" -o "$NODE_REPL_ELF_ROOT/static-pie" +cc -shared -fPIC "$NODE_REPL_ELF_ROOT/fixture.c" -o "$NODE_REPL_ELF_ROOT/no-interpreter-needed.so" +cc -shared -fPIC -nostdlib \ + "$NODE_REPL_ELF_ROOT/no-needed.c" \ + -o "$NODE_REPL_ELF_ROOT/no-interpreter-no-needed.so" +cp "$NODE_REPL_ELF_ROOT/dynamic" "$NODE_REPL_ELF_ROOT/interpreter-probe-failure" +printf '\177ELFmalformed' > "$NODE_REPL_ELF_ROOT/malformed" + +# shellcheck source=scripts/lib/nix-node-repl-elf.sh +. "$NIX_NODE_REPL_ELF_HELPER" + +static_before="$(sha256sum "$NODE_REPL_ELF_ROOT/static-pie")" +patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/static-pie" \ + /nix/store/test-dynamic-linker \ + /nix/store/test-rpath +static_after="$(sha256sum "$NODE_REPL_ELF_ROOT/static-pie")" +[ "$static_before" = "$static_after" ] || \ + fail "Nix node_repl ELF helper changed a static PIE binary" + +dynamic_interpreter=/nix/store/test-dynamic-linker +patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/dynamic" \ + "$dynamic_interpreter" \ + /nix/store/test-rpath +[ "$(patchelf --print-interpreter "$NODE_REPL_ELF_ROOT/dynamic")" = "$dynamic_interpreter" ] || \ + fail "Nix node_repl ELF helper did not preserve the requested interpreter" +[ "$(patchelf --print-rpath "$NODE_REPL_ELF_ROOT/dynamic")" = /nix/store/test-rpath ] || \ + fail "Nix node_repl ELF helper did not install the requested RPATH" + +if patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/no-interpreter-needed.so" \ + /nix/store/test-dynamic-linker \ + /nix/store/test-rpath; then + fail "Nix node_repl ELF helper accepted dynamic dependencies without an interpreter" +fi +if patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/no-interpreter-no-needed.so" \ + /nix/store/test-dynamic-linker \ + /nix/store/test-rpath; then + fail "Nix node_repl ELF helper accepted a dependency-free shared object" +fi +mkdir -p "$NODE_REPL_ELF_ROOT/fake-bin" +cat > "$NODE_REPL_ELF_ROOT/fake-bin/patchelf" <<'SH' +#!/bin/bash +if [ "$1" = --print-interpreter ]; then + exit 42 +fi +exec "$CHATGPT_TEST_REAL_PATCHELF" "$@" +SH +chmod 0755 "$NODE_REPL_ELF_ROOT/fake-bin/patchelf" +if PATH="$NODE_REPL_ELF_ROOT/fake-bin:$PATH" \ + CHATGPT_TEST_REAL_PATCHELF="$(command -v patchelf)" \ + patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/interpreter-probe-failure" \ + /nix/store/test-dynamic-linker \ + /nix/store/test-rpath; then + fail "Nix node_repl ELF helper ignored an interpreter probe failure" +fi +if patch_node_repl_elf_for_nix \ + "$NODE_REPL_ELF_ROOT/malformed" \ + /nix/store/test-dynamic-linker \ + /nix/store/test-rpath; then + fail "Nix node_repl ELF helper accepted malformed ELF" +fi + # This assertion intentionally matches the literal shell variables in the release gate. # shellcheck disable=SC2016 grep -Fq \ @@ -167,6 +247,10 @@ block = text[ text.index("mkChatGPTReleaseApp ="): text.index("chatgptReleaseApp = mkChatGPTReleaseApp") ] +package_block = text[ + text.index("buildChatGPT ="): + text.index("chatgpt = pkgs.lib.makeOverridable buildChatGPT") +] release_source_info = text[ text.index("flakeSourceDirty ="): text.index("releaseSandboxCanaryPathFile =") @@ -223,6 +307,18 @@ assert text.count( 'export CHATGPT_MANAGED_NODE_SOURCE="${managedNixNode}"' ) == 3 assert 'export CHATGPT_MANAGED_NODE_SOURCE="${pkgs.nodejs}"' not in text +node_repl_helper_source = package_block.index( + ". ${sourceRoot}/scripts/lib/nix-node-repl-elf.sh" +) +node_repl_patch = package_block.index( + "patch_node_repl_elf_for_nix", + node_repl_helper_source, +) +node_repl_app_patch = package_block.index( + '${patchNixInstalledApp "$out/opt/chatgpt"}', + node_repl_patch, +) +assert node_repl_helper_source < node_repl_patch < node_repl_app_patch assert 'export CHATGPT_ELECTRON_ZIP_SOURCE="${nixElectronZip}"' in block assert 'export CHATGPT_ELECTRON_ZIP_SOURCE="${nixElectronZip}"' in payload_block assert 'export CHATGPT_ELECTRON_ZIP_SOURCE="${electronZip}"' not in block diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index c2ae45b2e..38ef42125 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -231,7 +231,10 @@ JSON {"name":"browser","version":"0.1.0-alpha2","interface":{"category":"Engineering"}} JSON cat > "$resources_dir/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" <<'JS' -import{env as Ub}from"node:process";function Me(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e}function th(){let e=import.meta.__codexNativePipe;return e==null||typeof e.createConnection!="function"?null:e}var I2=new Set(["about:blank"]);function Gb(e){if(I2.has(e))return!0;let t;try{t=new URL(e)}catch{return!1}return t.protocol==="http:"||t.protocol==="https:"}class Uf{async fetchBlocked(e,t){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`${t} cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)}}var ys=e=>e==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use",Q6=e=>e.platform==="win32"?t4(e):e4(e),e4=async e=>{let t=ys(e.platform);return(await BE(t)).map(n=>NE.resolve(t,n))},t4=async e=>[];export function setupAtlasRuntime() {return Ub.XDG_CONFIG_HOME} + +function pc({apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}){return{apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}} +const Wu=async t=>{let e=globalThis.display;if(typeof e=="function"){await e(t);return}console.log(t)}; +async function $x(t={}){let e=globalThis.nodeRepl;if(e==null||typeof e.rpc!="function")throw new Error("Browser use requires a trusted Node REPL browser service");let o=e.rpc,a={setup:c=>o("browser",{method:"setup",params:c}),execute:c=>o("browser",{method:"execute",params:c})},{apiManifest:n,disabledMemberIds:s}=await a.setup(t.environment??"codex-app");return pc({apiManifest:n,disabledMemberIds:new Set(s),displayBridge:{displayImage:c=>e.emitImage(c),displayValue:c=>console.log(c)},executeAgentCommand:a.execute})}export{$x as setupBrowserRuntime}; JS } @@ -5209,14 +5212,23 @@ test_port_validation_rejects_oversized_numeric_values() { chmod +x "$start_script" set +e - CHATGPT_WEBVIEW_PORT="$huge_port" bash "$start_script" --help >"$launcher_stdout" 2>"$launcher_stderr" + HOME="$workspace/home" \ + XDG_CACHE_HOME="$workspace/cache" \ + XDG_CONFIG_HOME="$workspace/invalid-port-config" \ + XDG_STATE_HOME="$workspace/state" \ + CHATGPT_WEBVIEW_PORT="$huge_port" \ + bash "$start_script" --help >"$launcher_stdout" 2>"$launcher_stderr" rc=$? set -e [ "$rc" -ne 0 ] || fail "Expected launcher validation to reject oversized CHATGPT_WEBVIEW_PORT" assert_contains "$launcher_stderr" "CHATGPT_LINUX_WEBVIEW_PORT must be between 1 and 65535" assert_not_contains "$launcher_stderr" "integer expected" - XDG_CONFIG_HOME="$workspace/help-config" bash "$start_script" --help >"$launcher_stdout" 2>"$launcher_stderr" + HOME="$workspace/home" \ + XDG_CACHE_HOME="$workspace/cache" \ + XDG_CONFIG_HOME="$workspace/help-config" \ + XDG_STATE_HOME="$workspace/state" \ + bash "$start_script" --help >"$launcher_stdout" 2>"$launcher_stderr" assert_contains "$launcher_stdout" "electron-flags.conf" assert_file_not_exists "$workspace/help-config/chatgpt/electron-flags.conf" @@ -8759,17 +8771,25 @@ test_browser_use_node_repl_fallback_runtime() { info "Skipping x86_64-only Browser Use fallback runtime test" return 0 fi + if ! command -v ar >/dev/null 2>&1; then + info "Skipping Browser Use fallback runtime test because ar (binutils) is unavailable" + return 0 + fi local workspace="$TMP_DIR/browser-use-node-repl-fallback" local app_dir="$workspace/ChatGPT.app" local install_dir="$workspace/install" local archive_root="$workspace/archive-root" - local archive="$workspace/runtime.tar.xz" + local archive="$workspace/chatgpt-runtime.deb" + local data_archive="$workspace/data.tar.xz" + local control_archive="$workspace/control.tar.xz" + local debian_binary="$workspace/debian-binary" + local fallback_node_repl="$archive_root/usr/lib/chatgpt/resources/cua_node/bin/node_repl" local output_log="$workspace/output.log" local archive_sha local true_bin - mkdir -p "$workspace" "$install_dir/resources" "$archive_root/codex-primary-runtime/dependencies/bin" + mkdir -p "$workspace" "$install_dir/resources" "$(dirname "$fallback_node_repl")" make_fake_browser_official_app "$app_dir" # Simulate the current upstream DMG shape: node_repl is under cua_node/bin, @@ -8779,9 +8799,13 @@ test_browser_use_node_repl_fallback_runtime() { chmod +x "$app_dir/Contents/Resources/cua_node/bin/node_repl" true_bin="$(type -P true)" - cp "$true_bin" "$archive_root/codex-primary-runtime/dependencies/bin/node_repl" - chmod 0755 "$archive_root/codex-primary-runtime/dependencies/bin/node_repl" - tar -cJf "$archive" -C "$archive_root" codex-primary-runtime + cp "$true_bin" "$fallback_node_repl" + printf '%s\n' 'NODE_REPL_TRUSTED_SERVICES' 'NODE_REPL_TRUSTED_RPC_ENABLED' 'nodeRepl.rpc = function rpc' >> "$fallback_node_repl" + chmod 0755 "$fallback_node_repl" + tar -cJf "$data_archive" -C "$archive_root" usr + tar -cJf "$control_archive" --files-from /dev/null + printf '2.0\n' > "$debian_binary" + ar rcs "$archive" "$debian_binary" "$control_archive" "$data_archive" archive_sha="$(sha256sum "$archive" | awk '{print $1}')" ( @@ -8793,7 +8817,7 @@ test_browser_use_node_repl_fallback_runtime() { CHATGPT_APP_ID="chatgpt" XDG_CACHE_HOME="$workspace/xdg-cache" CODEX_NODE_REPL_PATH= - CHATGPT_LINUX_NODE_REPL_SOURCE= + CHATGPT_LINUX_NODE_REPL_SOURCE="$true_bin" CHATGPT_BROWSER_USE_RUNTIME_CACHE_DIR="$workspace/cache" CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_URL="file://$archive" CHATGPT_BROWSER_USE_NODE_REPL_RUNTIME_SHA256="$archive_sha" @@ -8814,12 +8838,13 @@ test_browser_use_node_repl_fallback_runtime() { assert_file_exists "$install_dir/resources/node_repl" assert_file_exists "$install_dir/resources/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" - cmp -s "$true_bin" "$install_dir/resources/node_repl" || fail "Expected fallback node_repl to come from the runtime archive" - assert_contains "$install_dir/resources/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" "chatgptLinuxSiteStatusAllowlistFallback" - assert_contains "$install_dir/resources/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" "chatgptLinuxFileUrlPolicy" + cmp -s "$fallback_node_repl" "$install_dir/resources/node_repl" || fail "Expected fallback node_repl to come from the official Linux package" + assert_contains "$install_dir/resources/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" "globalThis.nodeRepl" + assert_contains "$install_dir/resources/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" 'o("browser",{method:"setup"' assert_contains "$output_log" "Browser Use node_repl runtime is not a Linux executable for x86_64; skipping" assert_not_contains "$output_log" "WARN.*Browser Use node_repl runtime is not a Linux executable" - assert_contains "$output_log" "Downloading Browser Use node_repl fallback runtime" + assert_contains "$output_log" "does not implement the trusted Browser RPC contract; skipping" + assert_contains "$output_log" "Downloading Browser Use node_repl fallback package" } test_browser_use_file_url_policy_patch_behavior() { @@ -9055,21 +9080,15 @@ test_browser_plugin_renamed_upstream_staging() { assert_file_exists "$browser_dir/scripts/browser-client.mjs" assert_contains "$browser_dir/.codex-plugin/plugin.json" '"name":"browser"' - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseProcessEnv" - assert_not_contains "$browser_dir/scripts/browser-client.mjs" '"node:process"' - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseDefineNodeReplMethod" - assert_contains "$browser_dir/scripts/browser-client.mjs" "addAfterSubmittedCodeHook" - assert_contains "$browser_dir/scripts/browser-client.mjs" "nativePipe??import.meta.__codexNativePipe" - assert_not_contains "$browser_dir/scripts/browser-client.mjs" "let e=import.meta.__codexNativePipe;return" - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxSiteStatusAllowlistFallback" - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxFileUrlPolicy" - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxIabSocketScope" - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxPerUserBrowserSocketDir" - assert_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseUserInfo" - assert_not_contains "$browser_dir/scripts/browser-client.mjs" "process.env.CODEX_BROWSER_USE_SOCKET_DIR" - assert_not_contains "$browser_dir/scripts/browser-client.mjs" '"/tmp/codex-browser-use"' - assert_contains "$browser_dir/scripts/browser-client.mjs" 'protocol==="file:"' - assert_not_contains "$browser_dir/scripts/browser-client.mjs" 'protocol==="data:"' + assert_contains "$browser_dir/scripts/browser-client.mjs" "globalThis.nodeRepl" + assert_contains "$browser_dir/scripts/browser-client.mjs" 'typeof e.rpc!="function"' + assert_contains "$browser_dir/scripts/browser-client.mjs" 'o("browser",{method:"setup"' + assert_contains "$browser_dir/scripts/browser-client.mjs" 'o("browser",{method:"execute"' + assert_not_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseConfigShim" + assert_not_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseValidatedEnvironment" + assert_not_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseEnvironmentShim" + assert_not_contains "$browser_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseProcessEnv" + assert_not_contains "$browser_dir/scripts/browser-client.mjs" "node:process" assert_contains "$marketplace" '"name": "browser"' assert_contains "$marketplace" '"path": "./plugins/browser"' assert_contains "$output_log" "Browser plugin staged from official OpenAI DMG" @@ -9703,11 +9722,10 @@ MD {"extensionId":"hehggadaopoacecdllhhajmbjkdcmajg","extensionHostName":"com.openai.codexextension"} JSON cat > "$chrome_dir/scripts/browser-client.mjs" <<'JS' -const browserPreference={};function preferredWindowIdFor(){}function getForUrl(){}const extensionInstanceId=null; -var kE=t=>t==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use";var Cb=kE(hV.platform()),EV=()=>_P()==="win32"?TV():CV(),CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e)),TV=async()=>[]; -function Me(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e} -import{platform as yT}from"node:os";import{env as Ub}from"node:process";function eh(){return"privileged native pipe bridge is not available; browser-client is not trusted"}function th(){let e=globalThis.nodeRepl?.nativePipe;return e==null||typeof e.createConnection!="function"?null:e}var ml=class e{constructor(t){this.socket=t}static async create(t){let r=th();if(r!=null){let n=await r.createConnection(t);return new e(n)}throw new Error(eh())}};var chromeConfigHome=Ub.CHROME_CONFIG_HOME; -async fetchBlocked(e,t){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`${t} cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)} + +function pc({apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}){return{apiManifest:t,disabledMemberIds:e,displayBridge:o,executeAgentCommand:a}} +const Wu=async t=>{let e=globalThis.display;if(typeof e=="function"){await e(t);return}console.log(t)}; +async function $x(t={}){let e=globalThis.nodeRepl;if(e==null||typeof e.rpc!="function")throw new Error("Browser use requires a trusted Node REPL browser service");let o=e.rpc,a={setup:c=>o("browser",{method:"setup",params:c}),execute:c=>o("browser",{method:"execute",params:c})},{apiManifest:n,disabledMemberIds:s}=await a.setup(t.environment??"codex-app");return pc({apiManifest:n,disabledMemberIds:new Set(s),displayBridge:{displayImage:c=>e.emitImage(c),displayValue:c=>console.log(c)},executeAgentCommand:a.execute})}export{$x as setupBrowserRuntime}; JS cat > "$chrome_dir/scripts/check-native-host-manifest.js" <<'JS' #!/usr/bin/env node @@ -9884,30 +9902,18 @@ test_chrome_plugin_staging() { assert_contains "$chrome_dir/scripts/open-chrome-window.js" "defaultBrowser ===" assert_contains "$chrome_dir/scripts/open-chrome-window.js" "resolveChromeProfileDirectoryFromRunningProcess" assert_contains "$chrome_dir/scripts/open-chrome-window.js" "defaultLinuxUserDataDirectoryForCommand" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "browserPreference" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "preferredWindowIdFor" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "getForUrl" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseProcessEnv" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" '"node:process"' - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseConfigShim" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "writeValue: chatgptLinuxBrowserUseIgnoreConfigWrite" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "batchWrite: chatgptLinuxBrowserUseIgnoreConfigWrite" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "writeFile" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseStringifyToml" - assert_contains "$chrome_dir/scripts/browser-client.mjs" 'Object.getPrototypeOf(repl)' - assert_contains "$chrome_dir/scripts/browser-client.mjs" 'Object.defineProperty(prototype, "config"' - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseDefineNodeReplMethod" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "addAfterSubmittedCodeHook" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseConfigShim();let e=globalThis.nodeRepl" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "nativePipe??import.meta.__codexNativePipe" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxNativePipeFallback" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" 'await import("node:net")' - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxSiteStatusAllowlistFallback" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxPerUserBrowserSocketDir" - assert_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseUserInfo" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "process.env.CODEX_BROWSER_USE_SOCKET_DIR" - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" '"/tmp/codex-browser-use"' - assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxIabSocketScope" + assert_contains "$chrome_dir/scripts/browser-client.mjs" "globalThis.nodeRepl" + assert_contains "$chrome_dir/scripts/browser-client.mjs" 'typeof e.rpc!="function"' + assert_contains "$chrome_dir/scripts/browser-client.mjs" 'o("browser",{method:"setup"' + assert_contains "$chrome_dir/scripts/browser-client.mjs" 'o("browser",{method:"execute"' + assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseConfigShim" + assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseValidatedEnvironment" + assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseEnvironmentShim" + assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "chatgptLinuxBrowserUseProcessEnv" + assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "node:process" + [ "$(sha256sum "$chrome_dir/scripts/browser-client.mjs" | awk '{print $1}')" = \ + "2727f25c61bb0250b4143cc4149e768ee79874e6fc18dee11c712aa0a0fbd71b" ] \ + || fail "Expected Chrome client to remain the exact trusted RPC test vector" assert_contains "$chrome_dir/skills/control-chrome/SKILL.md" "agent.browsers.list()" assert_contains "$chrome_dir/skills/control-chrome/SKILL.md" "browser.tabs.new()" assert_contains "$install_dir/resources/plugins/openai-bundled/.agents/plugins/marketplace.json" '"name": "chrome"' @@ -10123,8 +10129,8 @@ JS node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 assert_contains "$extracted/.vite/build/main-test.js" '(process.platform===`win32`||process.platform===`linux`)&&!this.isAppQuitting&&!(typeof chatgptLinuxIsQuitInProgress===`function`&&chatgptLinuxIsQuitInProgress())' assert_contains "$extracted/.vite/build/main-test.js" 'r=chatgptLinuxRegisterTray(new n.Tray(t.defaultIcon))' - assert_contains "$extracted/.vite/build/main-test.js" 'isReady(){return process.platform===`linux`&&typeof this.tray.isReady!=`function`?!0:r.S(this.tray)}' - assert_contains "$extracted/.vite/build/main-test.js" 'waitForReady(){return process.platform===`linux`&&typeof this.tray.whenReady!=`function`?Promise.resolve(!0):r.W(this.tray)}' + assert_contains "$extracted/.vite/build/main-test.js" 'isReady(){if(process.platform!==`linux`)return r.S(this.tray);let e=this.tray;return typeof e.isReady==`function`?e.isReady():!0}' + assert_contains "$extracted/.vite/build/main-test.js" 'async waitForReady(){if(process.platform!==`linux`)return r.W(this.tray);let e=this.tray;if(typeof e.whenReady!=`function`)return!0;try{return await e.whenReady(),!0}catch{return!1}}' assert_contains "$extracted/.vite/build/main-test.js" 'chatgpt-linux-project-tray-icon.*app-test.png' assert_contains "$extracted/.vite/build/main-test.js" 'if(o.isEmpty()&&process.platform===`linux`)o=n.nativeImage.createFromPath' assert_contains "$extracted/.vite/build/main-test.js" 'updatePersistentTrayMenu(){process.platform===`linux`' @@ -10222,8 +10228,8 @@ NODE node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 assert_occurrence_count "$extracted/.vite/build/main-test.js" 'chatgptLinuxRegisterTray(new n.Tray(t.defaultIcon))' '1' assert_occurrence_count "$extracted/.vite/build/main-test.js" 'chatgpt-linux-project-tray-icon' '1' - assert_occurrence_count "$extracted/.vite/build/main-test.js" 'isReady(){return process.platform===`linux`&&typeof this.tray.isReady!=`function`?!0:r.S(this.tray)}' '1' - assert_occurrence_count "$extracted/.vite/build/main-test.js" 'waitForReady(){return process.platform===`linux`&&typeof this.tray.whenReady!=`function`?Promise.resolve(!0):r.W(this.tray)}' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'isReady(){if(process.platform!==`linux`)return r.S(this.tray);let e=this.tray;return typeof e.isReady==`function`?e.isReady():!0}' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'async waitForReady(){if(process.platform!==`linux`)return r.W(this.tray);let e=this.tray;if(typeof e.whenReady!=`function`)return!0;try{return await e.whenReady(),!0}catch{return!1}}' '1' assert_occurrence_count "$extracted/.vite/build/main-test.js" '!(typeof chatgptLinuxIsQuitInProgress===`function`&&chatgptLinuxIsQuitInProgress())' '1' assert_contains "$extracted/.vite/build/main-test.js" 'chatgptLinuxRegisterTray=e=>(chatgptLinuxTray=e,e)' assert_contains "$extracted/.vite/build/main-test.js" 'chatgptLinuxDestroyTray=()=>{if(process.platform!==`linux`)return;' @@ -10243,7 +10249,7 @@ test_linux_explicit_quit_patch_smoke() { bundle_body="$(cat <<'JS' const x={o:e=>e};let s=require(`node:url`),n=require(`electron`);n=x.o(n);let l=require(`node:os`);l=x.o(l);let i=require(`node:path`);i=x.o(i);let d=require(`node:util`),q=require(`node:crypto`),a=require(`node:fs`);a=x.o(a); var pb=class{getNativeTrayMenuItems(){return[{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}]}}; -function qB(r,o){if(o.type===`quit-app`){n.app.quit();return}return o} +function qB(e,o){if(o.type===`quit-app`){o.relaunch===!0&&(e.quitState?.allowQuitTemporarily(),n.app.relaunch()),n.app.quit();return}return o} n.app.on(`before-quit`,o=>{let s=BI(),c=t.sr().some(e=>e.status===`ACTIVE`);if(e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}let l=n.app.getName();if(n.dialog.showMessageBoxSync({type:`warning`,buttons:[`Quit`,`Cancel`],defaultId:0,cancelId:1,noLink:!0,title:`Quit ${l}?`,message:`Quit ${l}?`,detail:vB({hasInProgressLocalConversation:s,hasEnabledAutomations:c})})!==0){o.preventDefault();return}i.markQuitApproved(),g=!0,a.markAppQuitting()}); l.app.on(`will-quit`,e=>{if(y=!0,v)return;let t=()=>{U5(h,N5).then(()=>{g.dispose(),l.app.quit()})};if(r.shouldSkipDrainBeforeQuit()){e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),p(),m()]).then(t);return}e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),f.flush(),p(),m()]).then(t)}); JS @@ -10254,7 +10260,7 @@ JS assert_contains "$extracted/.vite/build/main-test.js" 'chatgptLinuxPrepareForExplicitQuit=()=>{chatgptLinuxExplicitQuitApproved=!0,chatgptLinuxMarkQuitInProgress()}' assert_contains "$extracted/.vite/build/main-test.js" 'chatgptLinuxShouldBypassQuitPrompt=()=>chatgptLinuxExplicitQuitApproved===!0' assert_contains "$extracted/.vite/build/main-test.js" '{label:this.systemQuitMenuItemLabel,click:()=>{typeof chatgptLinuxPrepareForExplicitQuit===`function`?chatgptLinuxPrepareForExplicitQuit():typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),n.app.quit()}}' - assert_contains "$extracted/.vite/build/main-test.js" 'if(o.type===`quit-app`){typeof chatgptLinuxPrepareForExplicitQuit===`function`?chatgptLinuxPrepareForExplicitQuit():typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),n.app.quit();return}' + assert_contains "$extracted/.vite/build/main-test.js" 'if(o.type===`quit-app`){o.relaunch===!0&&(e.quitState?.allowQuitTemporarily(),n.app.relaunch()),typeof chatgptLinuxPrepareForExplicitQuit===`function`?chatgptLinuxPrepareForExplicitQuit():typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),n.app.quit();return}' assert_contains "$extracted/.vite/build/main-test.js" 'if((typeof chatgptLinuxShouldBypassQuitPrompt===`function`&&chatgptLinuxShouldBypassQuitPrompt())||e||i.canQuitWithoutPrompt()||r||!s&&!c){process.platform===`linux`&&typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),g=!0,a.markAppQuitting();return}' assert_contains "$extracted/.vite/build/main-test.js" 'process.platform===`linux`&&typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress(),i.markQuitApproved(),g=!0,a.markAppQuitting()' assert_contains "$extracted/.vite/build/main-test.js" 'chatgptLinuxLogQuitDrainResults=e=>{' @@ -10283,7 +10289,7 @@ const helperStart = source.indexOf("let chatgptLinuxTray=null"); const helperEnd = source.indexOf(";n.app.on(`before-quit`,()=>chatgptLinuxDestroyTray())", helperStart) + 1; const helperSnippet = helperStart === -1 || helperEnd === 0 ? null : source.slice(helperStart, helperEnd); const traySnippet = source.match(/\{label:this\.systemQuitMenuItemLabel,click:\(\)=>\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}/)?.[0]; -const quitAppSnippet = source.match(/if\(o\.type===`quit-app`\)\{typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/)?.[0]; +const quitAppSnippet = source.match(/if\(o\.type===`quit-app`\)\{o\.relaunch===!0&&\(e\.quitState\?\.allowQuitTemporarily\(\),n\.app\.relaunch\(\)\),typeof chatgptLinuxPrepareForExplicitQuit===`function`\?chatgptLinuxPrepareForExplicitQuit\(\):typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/)?.[0]; const beforeQuitSnippet = source.match(/if\(\(typeof chatgptLinuxShouldBypassQuitPrompt===`function`&&chatgptLinuxShouldBypassQuitPrompt\(\)\)\|\|e\|\|i\.canQuitWithoutPrompt\(\)\|\|r\|\|!s&&!c\)\{process\.platform===`linux`&&typeof chatgptLinuxMarkQuitInProgress===`function`&&chatgptLinuxMarkQuitInProgress\(\),g=!0,a\.markAppQuitting\(\);return\}/)?.[0]; if (!helperSnippet || !traySnippet || !quitAppSnippet || !beforeQuitSnippet) { throw new Error("Could not extract explicit quit snippets"); @@ -10305,19 +10311,24 @@ function runTrayQuit({ withHelper = true } = {}) { return state; } -function runQuitApp({ withHelper = true } = {}) { - const state = { markCalls: 0, prepareCalls: 0, quitCalls: 0 }; - const app = { quit() { state.quitCalls += 1; } }; +function runQuitApp({ relaunch = false, withHelper = true } = {}) { + const state = { allowCalls: 0, markCalls: 0, prepareCalls: 0, quitCalls: 0, relaunchCalls: 0 }; + const app = { + quit() { state.quitCalls += 1; }, + relaunch() { state.relaunchCalls += 1; }, + }; + const context = { quitState: { allowQuitTemporarily() { state.allowCalls += 1; } } }; const mark = () => { state.markCalls += 1; }; const prepare = withHelper ? () => { state.prepareCalls += 1; mark(); } : undefined; const handler = new Function( "n", + "e", "chatgptLinuxPrepareForExplicitQuit", "chatgptLinuxMarkQuitInProgress", "o", `${quitAppSnippet};return null;`, ); - handler({ app }, prepare, mark, { type: "quit-app" }); + handler({ app }, context, prepare, mark, { relaunch, type: "quit-app" }); return state; } @@ -10347,17 +10358,22 @@ if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1) } state = runQuitApp(); -if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1) { +if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1 || state.allowCalls !== 0 || state.relaunchCalls !== 0) { throw new Error("quit-app IPC should prepare explicit quit before quitting"); } +state = runQuitApp({ relaunch: true }); +if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1 || state.allowCalls !== 1 || state.relaunchCalls !== 1) { + throw new Error("quit-app IPC should preserve relaunch preparation before explicit quit"); +} + state = runTrayQuit({ withHelper: false }); if (state.prepareCalls !== 0 || state.markCalls !== 1 || state.quitCalls !== 1) { throw new Error("tray quit should still fall back to the quit-in-progress marker"); } state = runQuitApp({ withHelper: false }); -if (state.prepareCalls !== 0 || state.markCalls !== 1 || state.quitCalls !== 1) { +if (state.prepareCalls !== 0 || state.markCalls !== 1 || state.quitCalls !== 1 || state.allowCalls !== 0 || state.relaunchCalls !== 0) { throw new Error("quit-app IPC should still fall back to the quit-in-progress marker"); } @@ -11898,6 +11914,10 @@ test_launcher_warm_start_recovery() { bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" CHATGPT_TEST_DISABLE_PIDFD=1 CHATGPT_TEST_KILL_DURING_PRELAUNCH=1 \ bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" + CHATGPT_TEST_NORMAL_LOCK_ONLY=1 CHATGPT_TEST_UNTRUSTED_COMPUTER_USE_ENDPOINT=1 \ + bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" + CHATGPT_TEST_NORMAL_LOCK_ONLY=1 CHATGPT_TEST_LIVE_COMPUTER_USE_SOCKET=1 \ + bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" } test_launcher_window_reopen_behavior() {