dextunnel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +211 -0
- package/README.md +112 -0
- package/SECURITY.md +27 -0
- package/SUPPORT.md +43 -0
- package/package.json +44 -0
- package/public/client-shared.js +1831 -0
- package/public/favicon.svg +11 -0
- package/public/host.html +29 -0
- package/public/host.js +2079 -0
- package/public/index.html +28 -0
- package/public/index.js +98 -0
- package/public/live-bridge-lifecycle.js +258 -0
- package/public/live-bridge-retry-state.js +61 -0
- package/public/live-selection-intent.js +79 -0
- package/public/remote-operator-state.js +316 -0
- package/public/remote.html +167 -0
- package/public/remote.js +3967 -0
- package/public/styles.css +2793 -0
- package/public/surface-view-state.js +89 -0
- package/public/voice-dictation.js +45 -0
- package/src/bin/desktop-rehydration-smoke.mjs +111 -0
- package/src/bin/dextunnel.mjs +41 -0
- package/src/bin/doctor.mjs +48 -0
- package/src/bin/launch-attest.mjs +39 -0
- package/src/bin/launch-status.mjs +49 -0
- package/src/bin/mobile-link-proxy.mjs +221 -0
- package/src/bin/mobile-proof.mjs +164 -0
- package/src/bin/mobile-transport-smoke.mjs +200 -0
- package/src/bin/probe-codex-app-server-write.mjs +36 -0
- package/src/bin/probe-codex-app-server.mjs +30 -0
- package/src/lib/agent-room-context.mjs +54 -0
- package/src/lib/agent-room-runtime.mjs +355 -0
- package/src/lib/agent-room-service.mjs +335 -0
- package/src/lib/agent-room-state.mjs +406 -0
- package/src/lib/agent-room-store.mjs +71 -0
- package/src/lib/agent-room-text.mjs +48 -0
- package/src/lib/app-server-contract.mjs +66 -0
- package/src/lib/app-server-runtime.mjs +60 -0
- package/src/lib/attachment-service.mjs +119 -0
- package/src/lib/bridge-api-handler.mjs +719 -0
- package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
- package/src/lib/bridge-status-builder.mjs +60 -0
- package/src/lib/codex-app-server-client.mjs +1511 -0
- package/src/lib/companion-state.mjs +453 -0
- package/src/lib/control-lease-service.mjs +180 -0
- package/src/lib/debug-harness-service.mjs +173 -0
- package/src/lib/desktop-integration.mjs +146 -0
- package/src/lib/desktop-rehydration-smoke.mjs +269 -0
- package/src/lib/dextunnel-cli.mjs +122 -0
- package/src/lib/discovery-docs.mjs +1321 -0
- package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
- package/src/lib/install-preflight.mjs +373 -0
- package/src/lib/interaction-resolution-service.mjs +185 -0
- package/src/lib/interaction-state.mjs +360 -0
- package/src/lib/launch-release-bar.mjs +158 -0
- package/src/lib/live-control-state.mjs +107 -0
- package/src/lib/live-payload-builder.mjs +298 -0
- package/src/lib/live-selection-transition-state.mjs +49 -0
- package/src/lib/live-transcript-state.mjs +549 -0
- package/src/lib/mobile-network-profile.mjs +39 -0
- package/src/lib/mock-codex-adapter.mjs +62 -0
- package/src/lib/operator-diagnostics.mjs +82 -0
- package/src/lib/repo-changes-service.mjs +527 -0
- package/src/lib/runtime-config.mjs +106 -0
- package/src/lib/selection-state-service.mjs +214 -0
- package/src/lib/session-store.mjs +355 -0
- package/src/lib/shared-room-state.mjs +473 -0
- package/src/lib/shared-selection-state.mjs +40 -0
- package/src/lib/sse-hub.mjs +35 -0
- package/src/lib/static-surface-service.mjs +71 -0
- package/src/lib/surface-access.mjs +189 -0
- package/src/lib/surface-presence-service.mjs +118 -0
- package/src/lib/surface-request-guard.mjs +52 -0
- package/src/lib/thread-sync-state.mjs +536 -0
- package/src/lib/watcher-lifecycle.mjs +287 -0
- package/src/server.mjs +1446 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export function createDebugHarnessService({
|
|
2
|
+
ADVISORY_PARTICIPANT_IDS = [],
|
|
3
|
+
appServerState,
|
|
4
|
+
broadcast = () => {},
|
|
5
|
+
buildLivePayload = () => ({}),
|
|
6
|
+
getDefaultCwd = () => process.cwd(),
|
|
7
|
+
liveState,
|
|
8
|
+
nowIso = () => new Date().toISOString(),
|
|
9
|
+
nowMs = () => Date.now(),
|
|
10
|
+
queueCompanionWakeup = () => {}
|
|
11
|
+
} = {}) {
|
|
12
|
+
function createDebugPendingInteraction(kind) {
|
|
13
|
+
const thread = liveState.selectedThreadSnapshot?.thread || null;
|
|
14
|
+
const threadId = thread?.id || liveState.selectedThreadId || "debug-thread";
|
|
15
|
+
const cwd = thread?.cwd || liveState.selectedProjectCwd || getDefaultCwd();
|
|
16
|
+
const requestId = `debug-${kind}-${nowMs()}`;
|
|
17
|
+
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case "command":
|
|
20
|
+
return {
|
|
21
|
+
actionKind: "approval",
|
|
22
|
+
approveLabel: "Approve once",
|
|
23
|
+
availableDecisions: ["accept", "acceptForSession", "decline", "cancel"],
|
|
24
|
+
canApproveForSession: true,
|
|
25
|
+
command: "npm test",
|
|
26
|
+
cwd,
|
|
27
|
+
debug: true,
|
|
28
|
+
declineLabel: "Decline",
|
|
29
|
+
detail: "Local-only harness request for command approval.",
|
|
30
|
+
kind: "command",
|
|
31
|
+
method: "item/commandExecution/requestApproval",
|
|
32
|
+
requestId,
|
|
33
|
+
sessionActionLabel: "Approve for session",
|
|
34
|
+
threadId,
|
|
35
|
+
title: "Debug command approval"
|
|
36
|
+
};
|
|
37
|
+
case "file_change":
|
|
38
|
+
return {
|
|
39
|
+
actionKind: "approval",
|
|
40
|
+
approveLabel: "Approve",
|
|
41
|
+
availableDecisions: ["accept", "decline", "cancel"],
|
|
42
|
+
canApproveForSession: false,
|
|
43
|
+
debug: true,
|
|
44
|
+
declineLabel: "Decline",
|
|
45
|
+
detail: "Local-only harness request for file-change approval.",
|
|
46
|
+
kind: "file_change",
|
|
47
|
+
method: "item/fileChange/requestApproval",
|
|
48
|
+
requestId,
|
|
49
|
+
sessionActionLabel: "",
|
|
50
|
+
threadId,
|
|
51
|
+
title: "Debug file change"
|
|
52
|
+
};
|
|
53
|
+
case "permissions":
|
|
54
|
+
return {
|
|
55
|
+
actionKind: "approval",
|
|
56
|
+
approveLabel: "Allow turn",
|
|
57
|
+
canApproveForSession: true,
|
|
58
|
+
debug: true,
|
|
59
|
+
declineLabel: "Decline",
|
|
60
|
+
detail: "Local-only harness request for permissions approval.",
|
|
61
|
+
kind: "permissions",
|
|
62
|
+
method: "item/permissions/requestApproval",
|
|
63
|
+
permissions: {
|
|
64
|
+
filesystem: "workspace-write",
|
|
65
|
+
network: true
|
|
66
|
+
},
|
|
67
|
+
requestId,
|
|
68
|
+
sessionActionLabel: "Allow session",
|
|
69
|
+
threadId,
|
|
70
|
+
title: "Debug permissions request"
|
|
71
|
+
};
|
|
72
|
+
case "user_input":
|
|
73
|
+
return {
|
|
74
|
+
actionKind: "user_input",
|
|
75
|
+
debug: true,
|
|
76
|
+
detail: "Local-only harness request for tool user input.",
|
|
77
|
+
kind: "tool_input",
|
|
78
|
+
method: "item/tool/requestUserInput",
|
|
79
|
+
questions: [
|
|
80
|
+
{
|
|
81
|
+
header: "Deploy note",
|
|
82
|
+
id: "deploy_note",
|
|
83
|
+
isOther: true,
|
|
84
|
+
options: [{ label: "Ship it" }, { label: "Hold for review" }],
|
|
85
|
+
question: "What note should be attached to this run?"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
header: "Token",
|
|
89
|
+
id: "token",
|
|
90
|
+
isSecret: true,
|
|
91
|
+
question: "Optional secret token"
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
requestId,
|
|
95
|
+
submitLabel: "Submit",
|
|
96
|
+
threadId,
|
|
97
|
+
title: "Debug user input"
|
|
98
|
+
};
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`Unsupported debug interaction kind: ${kind}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setDebugPendingInteraction(kind) {
|
|
105
|
+
if (liveState.pendingInteraction) {
|
|
106
|
+
throw new Error("Resolve the pending interaction before creating another one.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
liveState.pendingInteraction = createDebugPendingInteraction(kind);
|
|
110
|
+
liveState.lastError = null;
|
|
111
|
+
appServerState.lastInteraction = {
|
|
112
|
+
at: nowIso(),
|
|
113
|
+
kind,
|
|
114
|
+
source: "debug-harness",
|
|
115
|
+
status: "pending",
|
|
116
|
+
threadId: liveState.pendingInteraction.threadId
|
|
117
|
+
};
|
|
118
|
+
broadcast("live", buildLivePayload());
|
|
119
|
+
return buildLivePayload();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clearDebugPendingInteraction() {
|
|
123
|
+
if (liveState.pendingInteraction?.debug) {
|
|
124
|
+
liveState.pendingInteraction = null;
|
|
125
|
+
liveState.lastError = null;
|
|
126
|
+
appServerState.lastInteraction = {
|
|
127
|
+
at: nowIso(),
|
|
128
|
+
kind: "debug-clear",
|
|
129
|
+
source: "debug-harness",
|
|
130
|
+
status: "cleared",
|
|
131
|
+
threadId: liveState.selectedThreadId || null
|
|
132
|
+
};
|
|
133
|
+
broadcast("live", buildLivePayload());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return buildLivePayload();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setDebugCompanionWakeup({ advisorId = "", threadId = null, wakeKind = "summary" } = {}) {
|
|
140
|
+
const nextThreadId = threadId || liveState.selectedThreadId || liveState.selectedThreadSnapshot?.thread?.id || null;
|
|
141
|
+
if (!nextThreadId) {
|
|
142
|
+
throw new Error("Select a live session before creating a companion wakeup.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const normalizedWakeKind = String(wakeKind || "").trim().toLowerCase() === "review" ? "review" : "summary";
|
|
146
|
+
const normalizedAdvisorId = String(advisorId || "").trim().toLowerCase();
|
|
147
|
+
const nextAdvisorId =
|
|
148
|
+
normalizedAdvisorId || (normalizedWakeKind === "review" ? "oracle" : "gemini");
|
|
149
|
+
if (!ADVISORY_PARTICIPANT_IDS.includes(nextAdvisorId)) {
|
|
150
|
+
throw new Error(`Unsupported advisory participant: ${advisorId}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
queueCompanionWakeup({
|
|
154
|
+
advisorId: nextAdvisorId,
|
|
155
|
+
text:
|
|
156
|
+
normalizedWakeKind === "review"
|
|
157
|
+
? "Review ready: local-only wakeup harness seeded a review notice for this channel."
|
|
158
|
+
: "Summary ready: local-only wakeup harness seeded a summary notice for this channel.",
|
|
159
|
+
threadId: nextThreadId,
|
|
160
|
+
turnId: null,
|
|
161
|
+
wakeKey: `debug-${nextAdvisorId}-${normalizedWakeKind}:${nowMs()}`,
|
|
162
|
+
wakeKind: normalizedWakeKind
|
|
163
|
+
});
|
|
164
|
+
return buildLivePayload();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
clearDebugPendingInteraction,
|
|
169
|
+
createDebugPendingInteraction,
|
|
170
|
+
setDebugCompanionWakeup,
|
|
171
|
+
setDebugPendingInteraction
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export const CODEX_NAVIGATION_SEQUENCES = {
|
|
8
|
+
viewBackForward: [
|
|
9
|
+
{ itemTitle: "Back", menuTitle: "View" },
|
|
10
|
+
{ itemTitle: "Forward", menuTitle: "View" }
|
|
11
|
+
],
|
|
12
|
+
viewPreviousNextThread: [
|
|
13
|
+
{ itemTitle: "Previous Thread", menuTitle: "View" },
|
|
14
|
+
{ itemTitle: "Next Thread", menuTitle: "View" }
|
|
15
|
+
]
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function escapeAppleScriptString(value) {
|
|
19
|
+
return String(value || "").replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildCodexMenuItemAppleScript({
|
|
23
|
+
activate = true,
|
|
24
|
+
activationDelaySeconds = 0.2,
|
|
25
|
+
itemTitle,
|
|
26
|
+
menuTitle
|
|
27
|
+
} = {}) {
|
|
28
|
+
const escapedMenuTitle = escapeAppleScriptString(menuTitle);
|
|
29
|
+
const escapedItemTitle = escapeAppleScriptString(itemTitle);
|
|
30
|
+
const lines = [];
|
|
31
|
+
|
|
32
|
+
if (activate) {
|
|
33
|
+
lines.push('tell application "Codex" to activate');
|
|
34
|
+
if (activationDelaySeconds > 0) {
|
|
35
|
+
lines.push(`delay ${Number(activationDelaySeconds)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
lines.push('tell application "System Events"');
|
|
40
|
+
lines.push(' tell process "Codex"');
|
|
41
|
+
lines.push(
|
|
42
|
+
` click menu item "${escapedItemTitle}" of menu "${escapedMenuTitle}" of menu bar item "${escapedMenuTitle}" of menu bar 1`
|
|
43
|
+
);
|
|
44
|
+
lines.push(" end tell");
|
|
45
|
+
lines.push("end tell");
|
|
46
|
+
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function activateCodex({
|
|
51
|
+
activationDelayMs = 200,
|
|
52
|
+
openCommand = execFileAsync,
|
|
53
|
+
wait = delay
|
|
54
|
+
} = {}) {
|
|
55
|
+
await openCommand("osascript", ["-e", 'tell application "Codex" to activate']);
|
|
56
|
+
if (activationDelayMs > 0) {
|
|
57
|
+
await wait(activationDelayMs);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
activated: true
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runCodexMenuItem(
|
|
65
|
+
{
|
|
66
|
+
activate = true,
|
|
67
|
+
activationDelaySeconds = 0.2,
|
|
68
|
+
itemTitle,
|
|
69
|
+
menuTitle
|
|
70
|
+
} = {},
|
|
71
|
+
{ scriptCommand = execFileAsync } = {}
|
|
72
|
+
) {
|
|
73
|
+
if (!menuTitle || !itemTitle) {
|
|
74
|
+
throw new Error("menuTitle and itemTitle are required");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scriptLines = buildCodexMenuItemAppleScript({
|
|
78
|
+
activate,
|
|
79
|
+
activationDelaySeconds,
|
|
80
|
+
itemTitle,
|
|
81
|
+
menuTitle
|
|
82
|
+
});
|
|
83
|
+
const args = scriptLines.flatMap((line) => ["-e", line]);
|
|
84
|
+
await scriptCommand("osascript", args);
|
|
85
|
+
return {
|
|
86
|
+
activated: activate,
|
|
87
|
+
itemTitle,
|
|
88
|
+
menuTitle
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function runCodexNavigationSequence(
|
|
93
|
+
sequenceId,
|
|
94
|
+
{
|
|
95
|
+
activateEachStep = false,
|
|
96
|
+
activationDelaySeconds = 0.2,
|
|
97
|
+
runMenuItem = runCodexMenuItem,
|
|
98
|
+
stepDelayMs = 120,
|
|
99
|
+
wait = delay
|
|
100
|
+
} = {}
|
|
101
|
+
) {
|
|
102
|
+
const steps = CODEX_NAVIGATION_SEQUENCES[sequenceId];
|
|
103
|
+
if (!Array.isArray(steps)) {
|
|
104
|
+
throw new Error(`Unknown Codex navigation sequence: ${sequenceId}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const completed = [];
|
|
108
|
+
|
|
109
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
110
|
+
const step = steps[index];
|
|
111
|
+
const result = await runMenuItem(
|
|
112
|
+
{
|
|
113
|
+
activate: activateEachStep || index === 0,
|
|
114
|
+
activationDelaySeconds,
|
|
115
|
+
...step
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
completed.push(result);
|
|
119
|
+
if (index < steps.length - 1 && stepDelayMs > 0) {
|
|
120
|
+
await wait(stepDelayMs);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
sequenceId,
|
|
126
|
+
steps: completed
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function buildCodexThreadDeeplink(threadId) {
|
|
131
|
+
const normalized = String(threadId || "").trim();
|
|
132
|
+
if (!normalized) {
|
|
133
|
+
throw new Error("threadId is required");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `codex://threads/${encodeURIComponent(normalized)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function openThreadInCodex(threadId, { openCommand = execFileAsync } = {}) {
|
|
140
|
+
const deeplink = buildCodexThreadDeeplink(threadId);
|
|
141
|
+
await openCommand("open", [deeplink]);
|
|
142
|
+
return {
|
|
143
|
+
deeplink,
|
|
144
|
+
threadId: String(threadId || "").trim()
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
import { createCodexAppServerBridge } from "./codex-app-server-client.mjs";
|
|
5
|
+
import {
|
|
6
|
+
openThreadInCodex,
|
|
7
|
+
runCodexNavigationSequence
|
|
8
|
+
} from "./desktop-integration.mjs";
|
|
9
|
+
|
|
10
|
+
export const DESKTOP_REHYDRATION_ATTEMPTS = [
|
|
11
|
+
{
|
|
12
|
+
category: "desktop",
|
|
13
|
+
destructive: false,
|
|
14
|
+
expectedDesktopOutcome: "navigation-only",
|
|
15
|
+
id: "revealInCodex",
|
|
16
|
+
label: "Reveal in Codex"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
category: "appServer",
|
|
20
|
+
destructive: false,
|
|
21
|
+
expectedDesktopOutcome: "none",
|
|
22
|
+
id: "threadRead",
|
|
23
|
+
label: "app-server thread/read"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
category: "appServer",
|
|
27
|
+
destructive: false,
|
|
28
|
+
expectedDesktopOutcome: "negative",
|
|
29
|
+
id: "threadResume",
|
|
30
|
+
label: "app-server thread/resume"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
category: "desktop",
|
|
34
|
+
destructive: false,
|
|
35
|
+
expectedDesktopOutcome: "negative",
|
|
36
|
+
id: "viewBackForward",
|
|
37
|
+
label: "Codex View -> Back / Forward"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
category: "desktop",
|
|
41
|
+
destructive: false,
|
|
42
|
+
expectedDesktopOutcome: "negative",
|
|
43
|
+
id: "viewPreviousNextThread",
|
|
44
|
+
label: "Codex View -> Previous Thread / Next Thread"
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function defaultStamp(now = new Date()) {
|
|
49
|
+
const iso = now.toISOString().replace(/[-:.]/g, "");
|
|
50
|
+
return iso.replace(".000Z", "Z");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildProbe(promptStamp) {
|
|
54
|
+
const normalized = String(promptStamp || defaultStamp()).trim();
|
|
55
|
+
const prompt = `REHYDRATION_SMOKE_${normalized}. Reply with exactly: REHYDRATION_SMOKE_ACK_${normalized}.`;
|
|
56
|
+
const ack = `REHYDRATION_SMOKE_ACK_${normalized}`;
|
|
57
|
+
return {
|
|
58
|
+
ack,
|
|
59
|
+
prompt,
|
|
60
|
+
stamp: normalized
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function objectContainsMarker(value, marker) {
|
|
65
|
+
return JSON.stringify(value || {}).includes(marker);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringifyError(error) {
|
|
69
|
+
if (!error) {
|
|
70
|
+
return "Unknown error.";
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof Error) {
|
|
73
|
+
return error.message || error.name || "Error";
|
|
74
|
+
}
|
|
75
|
+
return String(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createManualChecks({ probe } = {}) {
|
|
79
|
+
return [
|
|
80
|
+
`After each attempt, check whether "${probe.prompt}" and "${probe.ack}" are visible in Codex without restarting.`,
|
|
81
|
+
"Expect Reveal in Codex to navigate only, not force desktop rehydration.",
|
|
82
|
+
"Expect thread/read and thread/resume to prove app-server state, not the desktop view.",
|
|
83
|
+
"Expect View navigation attempts to stay best-effort and currently negative unless Codex changed.",
|
|
84
|
+
`If you need a positive desktop visibility check, quit and reopen the Codex app manually and confirm "${probe.prompt}" and "${probe.ack}" appear afterward.`
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function waitForProbeReadback(
|
|
89
|
+
bridge,
|
|
90
|
+
threadId,
|
|
91
|
+
{
|
|
92
|
+
ack,
|
|
93
|
+
prompt,
|
|
94
|
+
probePollMs = 1200,
|
|
95
|
+
probeTimeoutMs = 45000,
|
|
96
|
+
seed = {},
|
|
97
|
+
wait = delay
|
|
98
|
+
} = {}
|
|
99
|
+
) {
|
|
100
|
+
let promptVisible = Boolean(seed.promptVisible);
|
|
101
|
+
let ackVisible = Boolean(seed.ackVisible);
|
|
102
|
+
let thread = seed.thread || null;
|
|
103
|
+
const startedAt = Date.now();
|
|
104
|
+
|
|
105
|
+
while (!(promptVisible && ackVisible)) {
|
|
106
|
+
thread = await bridge.readThread(threadId, true);
|
|
107
|
+
promptVisible = objectContainsMarker(thread, prompt);
|
|
108
|
+
ackVisible = objectContainsMarker(thread, ack);
|
|
109
|
+
|
|
110
|
+
if (promptVisible && ackVisible) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Date.now() - startedAt >= probeTimeoutMs) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (probePollMs > 0) {
|
|
119
|
+
await wait(probePollMs);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
ackVisible,
|
|
125
|
+
promptVisible,
|
|
126
|
+
status: promptVisible && ackVisible ? "persisted" : "readback-mismatch",
|
|
127
|
+
thread
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runDesktopRehydrationSmoke({
|
|
132
|
+
bridgeFactory = createCodexAppServerBridge,
|
|
133
|
+
cwd = process.cwd(),
|
|
134
|
+
includeProbe = true,
|
|
135
|
+
openThread = openThreadInCodex,
|
|
136
|
+
probePollMs = 1200,
|
|
137
|
+
promptStamp = defaultStamp(),
|
|
138
|
+
probeTimeoutMs = 45000,
|
|
139
|
+
runNavigationSequence = runCodexNavigationSequence,
|
|
140
|
+
threadId,
|
|
141
|
+
wait = delay
|
|
142
|
+
} = {}) {
|
|
143
|
+
const normalizedThreadId = String(threadId || "").trim();
|
|
144
|
+
if (!normalizedThreadId) {
|
|
145
|
+
throw new Error("threadId is required");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bridge = await bridgeFactory();
|
|
149
|
+
const probe = buildProbe(promptStamp);
|
|
150
|
+
const report = {
|
|
151
|
+
attempts: [],
|
|
152
|
+
cwd,
|
|
153
|
+
manualChecks: [],
|
|
154
|
+
probe: {
|
|
155
|
+
...probe,
|
|
156
|
+
included: includeProbe,
|
|
157
|
+
status: includeProbe ? "pending" : "skipped"
|
|
158
|
+
},
|
|
159
|
+
threadId: normalizedThreadId
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const pushAttempt = (entry) => {
|
|
163
|
+
report.attempts.push(entry);
|
|
164
|
+
return entry;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
if (includeProbe) {
|
|
169
|
+
const sendResult = await bridge.sendText({
|
|
170
|
+
createThreadIfMissing: false,
|
|
171
|
+
cwd,
|
|
172
|
+
text: probe.prompt,
|
|
173
|
+
threadId: normalizedThreadId,
|
|
174
|
+
timeoutMs: probeTimeoutMs
|
|
175
|
+
});
|
|
176
|
+
const readback = await waitForProbeReadback(bridge, normalizedThreadId, {
|
|
177
|
+
ack: probe.ack,
|
|
178
|
+
prompt: probe.prompt,
|
|
179
|
+
probePollMs,
|
|
180
|
+
probeTimeoutMs,
|
|
181
|
+
seed: {
|
|
182
|
+
ackVisible: objectContainsMarker(sendResult?.snapshot, probe.ack),
|
|
183
|
+
promptVisible: objectContainsMarker(sendResult?.snapshot, probe.prompt),
|
|
184
|
+
thread: sendResult?.thread || null
|
|
185
|
+
},
|
|
186
|
+
wait
|
|
187
|
+
});
|
|
188
|
+
report.probe = {
|
|
189
|
+
...probe,
|
|
190
|
+
ackVisible: readback.ackVisible,
|
|
191
|
+
included: true,
|
|
192
|
+
promptVisible: readback.promptVisible,
|
|
193
|
+
status: readback.status,
|
|
194
|
+
turnId: sendResult?.turn?.id || null,
|
|
195
|
+
turnStatus: sendResult?.turn?.status || null
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const skipDesktopAttempts = includeProbe && report.probe.status !== "persisted";
|
|
200
|
+
|
|
201
|
+
for (const attempt of DESKTOP_REHYDRATION_ATTEMPTS) {
|
|
202
|
+
if (skipDesktopAttempts && attempt.category === "desktop") {
|
|
203
|
+
pushAttempt({
|
|
204
|
+
...attempt,
|
|
205
|
+
detail: "Skipped because write/readback proof did not settle cleanly.",
|
|
206
|
+
status: "skipped"
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
let result = null;
|
|
213
|
+
|
|
214
|
+
switch (attempt.id) {
|
|
215
|
+
case "revealInCodex":
|
|
216
|
+
result = await openThread(normalizedThreadId);
|
|
217
|
+
break;
|
|
218
|
+
case "threadRead": {
|
|
219
|
+
const thread = await bridge.readThread(normalizedThreadId, true);
|
|
220
|
+
result = {
|
|
221
|
+
ackVisible: includeProbe ? objectContainsMarker(thread, probe.ack) : null,
|
|
222
|
+
promptVisible: includeProbe ? objectContainsMarker(thread, probe.prompt) : null,
|
|
223
|
+
threadId: thread?.id || normalizedThreadId
|
|
224
|
+
};
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "threadResume": {
|
|
228
|
+
const thread = await bridge.resumeThread({
|
|
229
|
+
cwd,
|
|
230
|
+
threadId: normalizedThreadId
|
|
231
|
+
});
|
|
232
|
+
result = {
|
|
233
|
+
ackVisible: includeProbe ? objectContainsMarker(thread, probe.ack) : null,
|
|
234
|
+
promptVisible: includeProbe ? objectContainsMarker(thread, probe.prompt) : null,
|
|
235
|
+
threadId: thread?.id || normalizedThreadId
|
|
236
|
+
};
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case "viewBackForward":
|
|
240
|
+
result = await runNavigationSequence("viewBackForward");
|
|
241
|
+
break;
|
|
242
|
+
case "viewPreviousNextThread":
|
|
243
|
+
result = await runNavigationSequence("viewPreviousNextThread");
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
throw new Error(`Unknown rehydration attempt: ${attempt.id}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
pushAttempt({
|
|
250
|
+
...attempt,
|
|
251
|
+
result,
|
|
252
|
+
status: "ok"
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
pushAttempt({
|
|
256
|
+
...attempt,
|
|
257
|
+
detail: stringifyError(error),
|
|
258
|
+
status: "failed"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
report.manualChecks = createManualChecks({ probe });
|
|
264
|
+
|
|
265
|
+
return report;
|
|
266
|
+
} finally {
|
|
267
|
+
await bridge.dispose?.();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function packageJsonPathFromImportMetaUrl(importMetaUrl) {
|
|
6
|
+
const scriptPath = fileURLToPath(importMetaUrl);
|
|
7
|
+
return path.resolve(path.dirname(scriptPath), "../../package.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readPackageVersion({ packageJsonPath }) {
|
|
11
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
12
|
+
return String(packageJson.version || "0.0.0");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderHelp({ version }) {
|
|
16
|
+
return [
|
|
17
|
+
`Dextunnel ${version}`,
|
|
18
|
+
"",
|
|
19
|
+
"Usage:",
|
|
20
|
+
" dextunnel serve [--host <host>] [--port <port>] [--network] [--expose-host-surface]",
|
|
21
|
+
" dextunnel doctor",
|
|
22
|
+
" dextunnel --help",
|
|
23
|
+
" dextunnel --version",
|
|
24
|
+
"",
|
|
25
|
+
"Commands:",
|
|
26
|
+
" serve Start the local Dextunnel bridge (default command).",
|
|
27
|
+
" doctor Run the local Codex bridge preflight checks.",
|
|
28
|
+
"",
|
|
29
|
+
"Options:",
|
|
30
|
+
" --host <host> Bind the bridge to a specific host.",
|
|
31
|
+
" --port <port> Bind the bridge to a specific port.",
|
|
32
|
+
" --network Bind to 0.0.0.0 for LAN or Tailscale access.",
|
|
33
|
+
" --expose-host-surface Allow the host surface beyond loopback.",
|
|
34
|
+
" -h, --help Show this help message.",
|
|
35
|
+
" -v, --version Show the package version."
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePort(value) {
|
|
40
|
+
const port = Number(value);
|
|
41
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
42
|
+
throw new Error(`Invalid port: ${value}`);
|
|
43
|
+
}
|
|
44
|
+
return String(port);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseServeArgs(args, { version }) {
|
|
48
|
+
const env = {};
|
|
49
|
+
|
|
50
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
51
|
+
const arg = args[index];
|
|
52
|
+
|
|
53
|
+
switch (arg) {
|
|
54
|
+
case "--help":
|
|
55
|
+
case "-h":
|
|
56
|
+
return { kind: "help", text: renderHelp({ version }) };
|
|
57
|
+
case "--version":
|
|
58
|
+
case "-v":
|
|
59
|
+
return { kind: "version", text: version };
|
|
60
|
+
case "--network":
|
|
61
|
+
env.DEXTUNNEL_HOST = "0.0.0.0";
|
|
62
|
+
break;
|
|
63
|
+
case "--expose-host-surface":
|
|
64
|
+
env.DEXTUNNEL_EXPOSE_HOST_SURFACE = "1";
|
|
65
|
+
break;
|
|
66
|
+
case "--host": {
|
|
67
|
+
const value = args[index + 1];
|
|
68
|
+
if (!value) {
|
|
69
|
+
throw new Error("Missing value for --host");
|
|
70
|
+
}
|
|
71
|
+
env.DEXTUNNEL_HOST = value;
|
|
72
|
+
index += 1;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "--port": {
|
|
76
|
+
const value = args[index + 1];
|
|
77
|
+
if (!value) {
|
|
78
|
+
throw new Error("Missing value for --port");
|
|
79
|
+
}
|
|
80
|
+
env.PORT = parsePort(value);
|
|
81
|
+
index += 1;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { kind: "serve", env };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseDextunnelCli(argv, { version }) {
|
|
93
|
+
const args = [...argv];
|
|
94
|
+
const first = args[0];
|
|
95
|
+
|
|
96
|
+
if (!first) {
|
|
97
|
+
return { kind: "serve", env: {} };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
switch (first) {
|
|
101
|
+
case "serve":
|
|
102
|
+
return parseServeArgs(args.slice(1), { version });
|
|
103
|
+
case "doctor":
|
|
104
|
+
if (args.length > 1) {
|
|
105
|
+
throw new Error("The doctor command does not accept extra arguments.");
|
|
106
|
+
}
|
|
107
|
+
return { kind: "doctor" };
|
|
108
|
+
case "help":
|
|
109
|
+
case "--help":
|
|
110
|
+
case "-h":
|
|
111
|
+
return { kind: "help", text: renderHelp({ version }) };
|
|
112
|
+
case "version":
|
|
113
|
+
case "--version":
|
|
114
|
+
case "-v":
|
|
115
|
+
return { kind: "version", text: version };
|
|
116
|
+
default:
|
|
117
|
+
if (first.startsWith("-")) {
|
|
118
|
+
return parseServeArgs(args, { version });
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Unknown command: ${first}`);
|
|
121
|
+
}
|
|
122
|
+
}
|