codex-relay 1.0.0 → 1.0.1-beta.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/README.md +24 -24
- package/dist/cli.js +7 -7
- package/dist/src.js +4 -2
- package/dist/src2.js +193 -44
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Codex Relay runs a local bridge server for the Codex Relay mobile app. Keep Code
|
|
|
13
13
|
Run the server from the workspace you want Codex to use:
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
npx codex-relay
|
|
16
|
+
npx codex-relay@latest
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing payload. Scan the QR code from the mobile app. If scanning is not available, paste the full pairing payload into the app.
|
|
@@ -21,7 +21,7 @@ The CLI prints a QR code, a mobile URL, and a `codex-relay://pair...` pairing pa
|
|
|
21
21
|
When the app shows an approval code, approve it on the computer:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
npx codex-relay approve XXXX-XXXX
|
|
24
|
+
npx codex-relay@latest approve XXXX-XXXX
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
After approval, the phone can list Codex threads, start new work, stream messages, and handle approval prompts from the local Codex runtime.
|
|
@@ -31,7 +31,7 @@ After approval, the phone can list Codex threads, start new work, stream message
|
|
|
31
31
|
To keep the relay running after the command returns:
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npx codex-relay --bg
|
|
34
|
+
npx codex-relay@latest --bg
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Background mode writes runtime files under `.codex-relay/` in the current directory:
|
|
@@ -44,7 +44,7 @@ Background mode writes runtime files under `.codex-relay/` in the current direct
|
|
|
44
44
|
Print the current pairing QR again:
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
|
-
npx codex-relay qr
|
|
47
|
+
npx codex-relay@latest qr
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Stop a background server with the printed process id:
|
|
@@ -56,25 +56,25 @@ kill -TERM <pid>
|
|
|
56
56
|
## Commands
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
|
-
npx codex-relay
|
|
59
|
+
npx codex-relay@latest
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Start the relay in the foreground.
|
|
63
63
|
|
|
64
64
|
```sh
|
|
65
|
-
npx codex-relay --bg
|
|
65
|
+
npx codex-relay@latest --bg
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
Start the relay in the background.
|
|
69
69
|
|
|
70
70
|
```sh
|
|
71
|
-
npx codex-relay qr
|
|
71
|
+
npx codex-relay@latest qr
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Print the latest pairing QR for an already running relay.
|
|
75
75
|
|
|
76
76
|
```sh
|
|
77
|
-
npx codex-relay approve XXXX-XXXX
|
|
77
|
+
npx codex-relay@latest approve XXXX-XXXX
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
Approve a pending mobile pairing request.
|
|
@@ -83,29 +83,29 @@ Approve a pending mobile pairing request.
|
|
|
83
83
|
|
|
84
84
|
The relay listens on `0.0.0.0:8787` by default. Configure it with environment variables:
|
|
85
85
|
|
|
86
|
-
| Variable | Purpose
|
|
87
|
-
| ----------------------------- |
|
|
88
|
-
| `PORT` | Server port. Defaults to `8787`.
|
|
89
|
-
| `HOST` | Listen host. Defaults to `0.0.0.0`.
|
|
90
|
-
| `CODEX_RELAY_PUBLIC_URL` | URL printed into the pairing QR, for example a Tailscale or tunnel URL.
|
|
91
|
-
| `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay`. |
|
|
92
|
-
| `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`.
|
|
93
|
-
| `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically.
|
|
94
|
-
| `CODEX_HOME` | Codex home directory, used when reading Codex session metadata.
|
|
95
|
-
| `CODEX_BIN` | Codex CLI executable path.
|
|
86
|
+
| Variable | Purpose |
|
|
87
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `PORT` | Server port. Defaults to `8787`. |
|
|
89
|
+
| `HOST` | Listen host. Defaults to `0.0.0.0`. |
|
|
90
|
+
| `CODEX_RELAY_PUBLIC_URL` | URL printed into the pairing QR, for example a Tailscale or tunnel URL. |
|
|
91
|
+
| `CODEX_RELAY_WORKSPACE_PATH` | Workspace path Codex should use. Defaults to the directory where you run `npx codex-relay@latest`. |
|
|
92
|
+
| `CODEX_RELAY_AUTH_DB_PATH` | Pairing and session database path. Defaults to `.codex-relay/auth.db`. |
|
|
93
|
+
| `CODEX_RELAY_APPROVAL_SECRET` | Secret used by the local approve command. Usually generated automatically. |
|
|
94
|
+
| `CODEX_HOME` | Codex home directory, used when reading Codex session metadata. |
|
|
95
|
+
| `CODEX_BIN` | Codex CLI executable path. |
|
|
96
96
|
|
|
97
97
|
Examples:
|
|
98
98
|
|
|
99
99
|
```sh
|
|
100
|
-
PORT=8788 npx codex-relay
|
|
100
|
+
PORT=8788 npx codex-relay@latest
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
```sh
|
|
104
|
-
CODEX_RELAY_PUBLIC_URL=http://100.64.0.10:8787 npx codex-relay
|
|
104
|
+
CODEX_RELAY_PUBLIC_URL=http://100.64.0.10:8787 npx codex-relay@latest
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
```sh
|
|
108
|
-
CODEX_RELAY_WORKSPACE_PATH=/path/to/project npx codex-relay
|
|
108
|
+
CODEX_RELAY_WORKSPACE_PATH=/path/to/project npx codex-relay@latest
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
## Network Notes
|
|
@@ -118,16 +118,16 @@ The phone must be able to reach the URL printed as `Mobile:` by the relay.
|
|
|
118
118
|
|
|
119
119
|
## Troubleshooting
|
|
120
120
|
|
|
121
|
-
If `npx codex-relay qr` cannot find a server, start one first:
|
|
121
|
+
If `npx codex-relay@latest qr` cannot find a server, start one first:
|
|
122
122
|
|
|
123
123
|
```sh
|
|
124
|
-
npx codex-relay
|
|
124
|
+
npx codex-relay@latest
|
|
125
125
|
```
|
|
126
126
|
|
|
127
127
|
If the relay says another process is using the local pairing database, use the existing server:
|
|
128
128
|
|
|
129
129
|
```sh
|
|
130
|
-
npx codex-relay qr
|
|
130
|
+
npx codex-relay@latest qr
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
Or stop the background process shown by the CLI:
|
package/dist/cli.js
CHANGED
|
@@ -31,7 +31,7 @@ async function startBackgroundServer() {
|
|
|
31
31
|
if (existingPid) {
|
|
32
32
|
console.log(`codex-relay is already running in the background (pid ${existingPid}).`);
|
|
33
33
|
console.log(`Logs: ${logPath}`);
|
|
34
|
-
console.log("Print the current pairing QR with: npx codex-relay qr");
|
|
34
|
+
console.log("Print the current pairing QR with: npx codex-relay@latest qr");
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
await unlink(pidPath).catch(() => void 0);
|
|
@@ -66,7 +66,7 @@ async function startBackgroundServer() {
|
|
|
66
66
|
}
|
|
67
67
|
console.log(`Started codex-relay in the background (pid ${startedPid}).`);
|
|
68
68
|
console.log(`Logs: ${logPath}`);
|
|
69
|
-
console.log("Print the pairing QR later with: npx codex-relay qr");
|
|
69
|
+
console.log("Print the pairing QR later with: npx codex-relay@latest qr");
|
|
70
70
|
}
|
|
71
71
|
async function readRunningPid(pidPath) {
|
|
72
72
|
const value = await readFile(pidPath, "utf8").catch(() => void 0);
|
|
@@ -126,8 +126,8 @@ async function printPairingQr() {
|
|
|
126
126
|
const state = storedState?.pairingPayload ? storedState : await readServerLogState();
|
|
127
127
|
if (!state?.pairingPayload) {
|
|
128
128
|
console.error("No running Codex Relay server state was found.");
|
|
129
|
-
console.error("Start the server first with: npx codex-relay");
|
|
130
|
-
console.error("Or run it in the background with: npx codex-relay --bg");
|
|
129
|
+
console.error("Start the server first with: npx codex-relay@latest");
|
|
130
|
+
console.error("Or run it in the background with: npx codex-relay@latest --bg");
|
|
131
131
|
process.exitCode = 1;
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
@@ -152,7 +152,7 @@ async function handleServerStartError(error) {
|
|
|
152
152
|
if (existingPid) {
|
|
153
153
|
console.error(`A background server appears to be running (pid ${existingPid}).`);
|
|
154
154
|
console.error("Use the existing server instead of starting a second one:");
|
|
155
|
-
console.error(" npx codex-relay qr");
|
|
155
|
+
console.error(" npx codex-relay@latest qr");
|
|
156
156
|
console.error("");
|
|
157
157
|
console.error("To stop the background server:");
|
|
158
158
|
console.error(` kill -TERM ${existingPid}`);
|
|
@@ -162,7 +162,7 @@ async function handleServerStartError(error) {
|
|
|
162
162
|
console.error("Another Codex Relay process is already running or exited without cleanup.");
|
|
163
163
|
if (state?.pairingPayload) {
|
|
164
164
|
console.error("Use the existing server instead of starting a second one:");
|
|
165
|
-
console.error(" npx codex-relay qr");
|
|
165
|
+
console.error(" npx codex-relay@latest qr");
|
|
166
166
|
console.error("");
|
|
167
167
|
}
|
|
168
168
|
console.error("Find it with:");
|
|
@@ -174,7 +174,7 @@ async function handleServerStartError(error) {
|
|
|
174
174
|
}
|
|
175
175
|
console.error("");
|
|
176
176
|
console.error("If you wanted a persistent server, start it once with:");
|
|
177
|
-
console.error(" npx codex-relay --bg");
|
|
177
|
+
console.error(" npx codex-relay@latest --bg");
|
|
178
178
|
process.exitCode = 1;
|
|
179
179
|
}
|
|
180
180
|
async function readApprovalSecret() {
|
package/dist/src.js
CHANGED
|
@@ -181,8 +181,9 @@ const WorkspaceChangesResponseSchema = z.object({
|
|
|
181
181
|
worktreeStatus: z.string().nullable()
|
|
182
182
|
})).default([])
|
|
183
183
|
});
|
|
184
|
-
const
|
|
185
|
-
const
|
|
184
|
+
const WorkspaceSelectionRequestSchema = z.object({ workspacePath: z.string().trim().min(1).optional() });
|
|
185
|
+
const CheckoutWorkspaceBranchRequestSchema = WorkspaceSelectionRequestSchema.extend({ branch: z.string().trim().min(1).refine((branch) => !branch.startsWith("-"), "Branch name cannot start with '-'.") });
|
|
186
|
+
const CommitPushWorkspaceRequestSchema = WorkspaceSelectionRequestSchema.extend({ message: z.string().trim().min(1).max(240) });
|
|
186
187
|
const WorkspaceGitActionResponseSchema = z.object({
|
|
187
188
|
branch: z.string().nullable(),
|
|
188
189
|
message: z.string().min(1),
|
|
@@ -206,6 +207,7 @@ const WebPreviewTargetSchema = z.object({
|
|
|
206
207
|
detectedAt: IsoDateTimeSchema
|
|
207
208
|
});
|
|
208
209
|
const PairRequestSchema = z.object({
|
|
210
|
+
clientSessionId: z.string().trim().min(1).max(120).optional(),
|
|
209
211
|
clientName: z.string().trim().min(1).max(80).optional(),
|
|
210
212
|
secure: z.object({
|
|
211
213
|
clientEphemeralPublicKey: z.string().min(1),
|
package/dist/src2.js
CHANGED
|
@@ -540,7 +540,8 @@ function createApp(options = {}) {
|
|
|
540
540
|
const tokenHash = token ? options.pairing.hashClientToken(token) : void 0;
|
|
541
541
|
const validSession = tokenHash ? await options.pairing.sessions.getValidSession(tokenHash, Date.now()) : void 0;
|
|
542
542
|
if (!tokenHash || !validSession) return c.json(apiError("unauthorized", "Pair this device with the Codex Relay server."), 401);
|
|
543
|
-
if (options.pairing.serverIdentity && !secureSessionsByTokenHash.has(tokenHash))
|
|
543
|
+
if (options.pairing.serverIdentity && !secureSessionsByTokenHash.has(tokenHash)) if (validSession.secureSession) secureSessionsByTokenHash.set(tokenHash, validSession.secureSession);
|
|
544
|
+
else return c.json(apiError("secure_session_required", "Secure session expired. Pair this device again."), 401);
|
|
544
545
|
await next();
|
|
545
546
|
});
|
|
546
547
|
app.post(apiPaths.pair, async (c) => {
|
|
@@ -556,6 +557,7 @@ function createApp(options = {}) {
|
|
|
556
557
|
approved: false,
|
|
557
558
|
approvalCode,
|
|
558
559
|
clientEphemeralPublicKey: parsed.data.secure.clientEphemeralPublicKey,
|
|
560
|
+
clientSessionId: parsed.data.clientSessionId,
|
|
559
561
|
clientName: parsed.data.clientName,
|
|
560
562
|
clientNonce: parsed.data.secure.clientNonce,
|
|
561
563
|
expiresAt,
|
|
@@ -584,15 +586,6 @@ function createApp(options = {}) {
|
|
|
584
586
|
const clientToken = options.pairing.createClientToken();
|
|
585
587
|
const expiresAt = Date.now() + options.pairing.tokenTtlMs;
|
|
586
588
|
const tokenHash = options.pairing.hashClientToken(clientToken);
|
|
587
|
-
const tokenCount = await options.pairing.sessions.createSession(tokenHash, {
|
|
588
|
-
clientName: pending.clientName,
|
|
589
|
-
expiresAt
|
|
590
|
-
});
|
|
591
|
-
await options.pairing.sessions.deletePendingPairing(approvalCode);
|
|
592
|
-
options.pairing.onPaired?.({
|
|
593
|
-
clientName: pending.clientName,
|
|
594
|
-
tokenCount
|
|
595
|
-
});
|
|
596
589
|
const clientTokenExpiresAt = new Date(expiresAt).toISOString();
|
|
597
590
|
const pairing = createSecurePairing({
|
|
598
591
|
approvalCode,
|
|
@@ -604,6 +597,17 @@ function createApp(options = {}) {
|
|
|
604
597
|
serverIdentity: options.pairing.serverIdentity,
|
|
605
598
|
serverUrl: pending.serverUrl
|
|
606
599
|
});
|
|
600
|
+
const tokenCount = await options.pairing.sessions.createSession(tokenHash, {
|
|
601
|
+
clientSessionId: pending.clientSessionId,
|
|
602
|
+
clientName: pending.clientName,
|
|
603
|
+
expiresAt,
|
|
604
|
+
secureSession: pairing.session
|
|
605
|
+
});
|
|
606
|
+
await options.pairing.sessions.deletePendingPairing(approvalCode);
|
|
607
|
+
options.pairing.onPaired?.({
|
|
608
|
+
clientName: pending.clientName,
|
|
609
|
+
tokenCount
|
|
610
|
+
});
|
|
607
611
|
secureSessionsByTokenHash.set(tokenHash, pairing.session);
|
|
608
612
|
return c.json(PairResponseSchema.parse({ secure: pairing.response }), 201);
|
|
609
613
|
});
|
|
@@ -630,15 +634,15 @@ function createApp(options = {}) {
|
|
|
630
634
|
const expiresAt = Date.now() + options.pairing.tokenTtlMs;
|
|
631
635
|
const oldTokenHash = options.pairing.hashClientToken(oldToken);
|
|
632
636
|
const newTokenHash = options.pairing.hashClientToken(clientToken);
|
|
637
|
+
const clientSessionId = normalizeClientSessionId(c.req.header("x-codex-relay-client-session-id")) ?? oldSession.clientSessionId;
|
|
633
638
|
const tokenCount = await options.pairing.sessions.rotateSession(oldTokenHash, newTokenHash, {
|
|
639
|
+
clientSessionId,
|
|
634
640
|
clientName: oldSession.clientName,
|
|
635
|
-
expiresAt
|
|
641
|
+
expiresAt,
|
|
642
|
+
secureSession: secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession
|
|
636
643
|
});
|
|
637
|
-
const secureSession = secureSessionsByTokenHash.get(oldTokenHash);
|
|
638
|
-
if (secureSession)
|
|
639
|
-
secureSessionsByTokenHash.delete(oldTokenHash);
|
|
640
|
-
secureSessionsByTokenHash.set(newTokenHash, secureSession);
|
|
641
|
-
}
|
|
644
|
+
const secureSession = secureSessionsByTokenHash.get(oldTokenHash) ?? oldSession.secureSession;
|
|
645
|
+
if (secureSession) secureSessionsByTokenHash.set(oldTokenHash, secureSession);
|
|
642
646
|
options.pairing.onTokenRefreshed?.({
|
|
643
647
|
clientName: oldSession.clientName,
|
|
644
648
|
tokenCount
|
|
@@ -647,7 +651,13 @@ function createApp(options = {}) {
|
|
|
647
651
|
clientToken,
|
|
648
652
|
clientTokenExpiresAt: new Date(expiresAt).toISOString()
|
|
649
653
|
});
|
|
650
|
-
|
|
654
|
+
const jsonResponse = await secureJson(c, options.pairing, secureSessionsByTokenHash, response, 201);
|
|
655
|
+
if (secureSession) {
|
|
656
|
+
secureSessionsByTokenHash.delete(oldTokenHash);
|
|
657
|
+
secureSessionsByTokenHash.set(newTokenHash, secureSession);
|
|
658
|
+
await options.pairing.sessions.updateSecureSession(newTokenHash, secureSession);
|
|
659
|
+
}
|
|
660
|
+
return jsonResponse;
|
|
651
661
|
});
|
|
652
662
|
app.get(apiPaths.status, (c) => {
|
|
653
663
|
const response = StatusResponseSchema.parse({
|
|
@@ -682,9 +692,11 @@ function createApp(options = {}) {
|
|
|
682
692
|
});
|
|
683
693
|
app.get(apiPaths.workspaceChanges, async (c) => {
|
|
684
694
|
try {
|
|
685
|
-
const
|
|
695
|
+
const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, c.req.query("workspacePath"));
|
|
696
|
+
if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
|
|
697
|
+
const changes = await readWorkspaceChanges(selectedWorkspacePath.path);
|
|
686
698
|
const response = WorkspaceChangesResponseSchema.parse({
|
|
687
|
-
workspacePath,
|
|
699
|
+
workspacePath: selectedWorkspacePath.path,
|
|
688
700
|
...changes
|
|
689
701
|
});
|
|
690
702
|
return secureJson(c, options.pairing, secureSessionsByTokenHash, response);
|
|
@@ -696,9 +708,11 @@ function createApp(options = {}) {
|
|
|
696
708
|
const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CheckoutWorkspaceBranchRequestSchema);
|
|
697
709
|
if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
|
|
698
710
|
try {
|
|
699
|
-
const
|
|
711
|
+
const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
|
|
712
|
+
if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
|
|
713
|
+
const output = await git(selectedWorkspacePath.path, ["checkout", parsed.data.branch]);
|
|
700
714
|
const response = WorkspaceGitActionResponseSchema.parse({
|
|
701
|
-
branch: await currentGitBranch(
|
|
715
|
+
branch: await currentGitBranch(selectedWorkspacePath.path),
|
|
702
716
|
message: `Checked out ${parsed.data.branch}.`,
|
|
703
717
|
output
|
|
704
718
|
});
|
|
@@ -711,23 +725,25 @@ function createApp(options = {}) {
|
|
|
711
725
|
const parsed = await parseRequestJson(c, options.pairing, secureSessionsByTokenHash, CommitPushWorkspaceRequestSchema);
|
|
712
726
|
if (!parsed.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, validationError(parsed.error), 400);
|
|
713
727
|
try {
|
|
714
|
-
await
|
|
715
|
-
|
|
728
|
+
const selectedWorkspacePath = await validateThreadWorkspacePath(workspacePath, parsed.data.workspacePath);
|
|
729
|
+
if (!selectedWorkspacePath.success) return secureJson(c, options.pairing, secureSessionsByTokenHash, apiError("invalid_workspace_path", selectedWorkspacePath.error), 400);
|
|
730
|
+
await git(selectedWorkspacePath.path, ["add", "--all"]);
|
|
731
|
+
const commitOutput = await git(selectedWorkspacePath.path, [
|
|
716
732
|
"commit",
|
|
717
733
|
"-m",
|
|
718
734
|
parsed.data.message
|
|
719
735
|
]);
|
|
720
|
-
const branch = await currentGitBranch(
|
|
721
|
-
const pushOutput = await git(
|
|
736
|
+
const branch = await currentGitBranch(selectedWorkspacePath.path);
|
|
737
|
+
const pushOutput = await git(selectedWorkspacePath.path, [
|
|
722
738
|
"rev-parse",
|
|
723
739
|
"--abbrev-ref",
|
|
724
740
|
"@{upstream}"
|
|
725
|
-
]).catch(() => null) ? await git(
|
|
741
|
+
]).catch(() => null) ? await git(selectedWorkspacePath.path, ["push"]) : branch ? await git(selectedWorkspacePath.path, [
|
|
726
742
|
"push",
|
|
727
743
|
"-u",
|
|
728
744
|
"origin",
|
|
729
745
|
branch
|
|
730
|
-
]) : await git(
|
|
746
|
+
]) : await git(selectedWorkspacePath.path, ["push"]);
|
|
731
747
|
const response = WorkspaceGitActionResponseSchema.parse({
|
|
732
748
|
branch,
|
|
733
749
|
message: "Committed and pushed workspace changes.",
|
|
@@ -981,6 +997,10 @@ function normalizeApprovalCode(value) {
|
|
|
981
997
|
const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").replaceAll("O", "0").replaceAll("I", "1");
|
|
982
998
|
return normalized.length === 8 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized;
|
|
983
999
|
}
|
|
1000
|
+
function normalizeClientSessionId(value) {
|
|
1001
|
+
const trimmed = value?.trim();
|
|
1002
|
+
return trimmed ? trimmed.slice(0, 120) : void 0;
|
|
1003
|
+
}
|
|
984
1004
|
async function validateThreadWorkspacePath(rootPath, requestedPath) {
|
|
985
1005
|
const resolved = resolve(requestedPath ?? rootPath);
|
|
986
1006
|
try {
|
|
@@ -1513,7 +1533,8 @@ async function parseRequestJson(c, pairing, secureSessionsByTokenHash, schema) {
|
|
|
1513
1533
|
const envelope = EncryptedPayloadSchema.safeParse(payload);
|
|
1514
1534
|
if (!envelope.success) return schema.safeParse({ __invalidEncryptedPayload: true });
|
|
1515
1535
|
try {
|
|
1516
|
-
payload = JSON.parse(decryptFromMobile(secureSession, envelope.data));
|
|
1536
|
+
payload = JSON.parse(decryptFromMobile(secureSession.session, envelope.data));
|
|
1537
|
+
await secureSession.persist();
|
|
1517
1538
|
} catch {
|
|
1518
1539
|
payload = { __invalidEncryptedPayload: true };
|
|
1519
1540
|
}
|
|
@@ -1529,15 +1550,30 @@ async function parsePlainJson(request, schema) {
|
|
|
1529
1550
|
}
|
|
1530
1551
|
return schema.safeParse(payload);
|
|
1531
1552
|
}
|
|
1532
|
-
function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
|
|
1553
|
+
async function secureJson(c, pairing, secureSessionsByTokenHash, payload, status) {
|
|
1533
1554
|
const secureSession = getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash);
|
|
1534
1555
|
if (!secureSession) return c.json(payload, status);
|
|
1535
|
-
const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(payload)));
|
|
1556
|
+
const encrypted = EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(payload)));
|
|
1557
|
+
await secureSession.persist();
|
|
1536
1558
|
return c.json(encrypted, status);
|
|
1537
1559
|
}
|
|
1538
1560
|
function getSecureSessionForRequest(c, pairing, secureSessionsByTokenHash) {
|
|
1539
1561
|
const token = parseBearerToken(c.req.header("authorization"));
|
|
1540
|
-
|
|
1562
|
+
if (!token || !pairing) return;
|
|
1563
|
+
const tokenHash = pairing.hashClientToken(token);
|
|
1564
|
+
const session = secureSessionsByTokenHash.get(tokenHash);
|
|
1565
|
+
return session ? createSecureSessionHandle(pairing, tokenHash, session) : void 0;
|
|
1566
|
+
}
|
|
1567
|
+
function createSecureSessionHandle(pairing, tokenHash, session) {
|
|
1568
|
+
let pendingPersist = Promise.resolve();
|
|
1569
|
+
return {
|
|
1570
|
+
persist: () => {
|
|
1571
|
+
pendingPersist = pendingPersist.catch(() => void 0).then(() => pairing.sessions.updateSecureSession(tokenHash, session));
|
|
1572
|
+
return pendingPersist;
|
|
1573
|
+
},
|
|
1574
|
+
session,
|
|
1575
|
+
tokenHash
|
|
1576
|
+
};
|
|
1541
1577
|
}
|
|
1542
1578
|
function sortedThreads(threads) {
|
|
1543
1579
|
return [...threads.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -1664,7 +1700,8 @@ function updateThread(threads, messagesByThreadId, threadId, update) {
|
|
|
1664
1700
|
}
|
|
1665
1701
|
function sendSse(controller, encoder, secureSession, event) {
|
|
1666
1702
|
const parsed = StreamThreadRunEventSchema.parse(event);
|
|
1667
|
-
const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession, JSON.stringify(parsed))) : parsed;
|
|
1703
|
+
const data = secureSession ? EncryptedPayloadSchema.parse(encryptForMobile(secureSession.session, JSON.stringify(parsed))) : parsed;
|
|
1704
|
+
if (secureSession) secureSession.persist().catch(() => void 0);
|
|
1668
1705
|
controller.enqueue(encoder.encode(`event: ${parsed.type}\n`));
|
|
1669
1706
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
|
1670
1707
|
}
|
|
@@ -2417,7 +2454,7 @@ function normalizeFileChanges(value) {
|
|
|
2417
2454
|
async function readWorkspaceChanges(workspacePath) {
|
|
2418
2455
|
const repo = await openRepository(workspacePath);
|
|
2419
2456
|
const [currentBranch, branches] = await Promise.all([currentGitBranch(workspacePath), listGitBranches(workspacePath)]);
|
|
2420
|
-
const statusEntries = collectIterator(repo.statuses().iter());
|
|
2457
|
+
const statusEntries = collectIterator(repo.statuses().iter()).filter((entry) => !entry.status().ignored);
|
|
2421
2458
|
const statusByPath = new Map(statusEntries.map((entry) => [entry.path(), entry]));
|
|
2422
2459
|
const status = statusEntries.map((entry) => formatStatusLine(entry.path(), entry.status())).join("\n");
|
|
2423
2460
|
const structuredDiff = createWorkspaceDiff(repo);
|
|
@@ -2465,6 +2502,11 @@ async function readWorkspaceChanges(workspacePath) {
|
|
|
2465
2502
|
});
|
|
2466
2503
|
}
|
|
2467
2504
|
const files = [...filesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
2505
|
+
const fileStats = {
|
|
2506
|
+
additions: files.reduce((total, file) => total + file.additions, 0),
|
|
2507
|
+
deletions: files.reduce((total, file) => total + file.deletions, 0),
|
|
2508
|
+
filesChanged: files.length
|
|
2509
|
+
};
|
|
2468
2510
|
return {
|
|
2469
2511
|
branches,
|
|
2470
2512
|
currentBranch,
|
|
@@ -2472,10 +2514,10 @@ async function readWorkspaceChanges(workspacePath) {
|
|
|
2472
2514
|
files,
|
|
2473
2515
|
hasChanges: files.length > 0 || Boolean(status.trim() || diff.trim()),
|
|
2474
2516
|
status,
|
|
2475
|
-
stats: {
|
|
2517
|
+
stats: files.length > 0 ? fileStats : {
|
|
2476
2518
|
additions: Number(stats.insertions),
|
|
2477
2519
|
deletions: Number(stats.deletions),
|
|
2478
|
-
filesChanged:
|
|
2520
|
+
filesChanged: Number(stats.filesChanged)
|
|
2479
2521
|
}
|
|
2480
2522
|
};
|
|
2481
2523
|
}
|
|
@@ -2596,14 +2638,21 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2596
2638
|
await db.exec(`
|
|
2597
2639
|
CREATE TABLE IF NOT EXISTS pairing_sessions (
|
|
2598
2640
|
token_hash TEXT PRIMARY KEY,
|
|
2641
|
+
client_session_id TEXT,
|
|
2599
2642
|
client_name TEXT,
|
|
2600
2643
|
expires_at INTEGER NOT NULL,
|
|
2644
|
+
key_epoch INTEGER,
|
|
2645
|
+
mobile_to_server_key TEXT,
|
|
2646
|
+
server_to_mobile_key TEXT,
|
|
2647
|
+
last_mobile_counter INTEGER,
|
|
2648
|
+
next_server_counter INTEGER,
|
|
2601
2649
|
created_at INTEGER NOT NULL,
|
|
2602
2650
|
updated_at INTEGER NOT NULL
|
|
2603
2651
|
);
|
|
2604
2652
|
|
|
2605
2653
|
CREATE TABLE IF NOT EXISTS pending_pairings (
|
|
2606
2654
|
approval_code TEXT PRIMARY KEY,
|
|
2655
|
+
client_session_id TEXT,
|
|
2607
2656
|
client_name TEXT,
|
|
2608
2657
|
client_ephemeral_public_key TEXT NOT NULL,
|
|
2609
2658
|
client_nonce TEXT NOT NULL,
|
|
@@ -2614,8 +2663,9 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2614
2663
|
updated_at INTEGER NOT NULL
|
|
2615
2664
|
);
|
|
2616
2665
|
`);
|
|
2666
|
+
await ensurePairingSessionColumns();
|
|
2617
2667
|
async function countActive(now) {
|
|
2618
|
-
const row = await db.prepare("SELECT COUNT(
|
|
2668
|
+
const row = await db.prepare("SELECT COUNT(DISTINCT COALESCE(client_session_id, token_hash)) AS count FROM pairing_sessions WHERE expires_at > ?").get(now);
|
|
2619
2669
|
return Number(row?.count ?? 0);
|
|
2620
2670
|
}
|
|
2621
2671
|
async function deleteSession(tokenHash) {
|
|
@@ -2626,6 +2676,7 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2626
2676
|
}
|
|
2627
2677
|
async function getPendingPairing(approvalCode, now) {
|
|
2628
2678
|
const row = await db.prepare(`SELECT approval_code AS approvalCode,
|
|
2679
|
+
client_session_id AS clientSessionId,
|
|
2629
2680
|
client_name AS clientName,
|
|
2630
2681
|
client_ephemeral_public_key AS clientEphemeralPublicKey,
|
|
2631
2682
|
client_nonce AS clientNonce,
|
|
@@ -2644,6 +2695,7 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2644
2695
|
approvalCode: String(row.approvalCode),
|
|
2645
2696
|
approved: Number(row.approved) === 1,
|
|
2646
2697
|
clientEphemeralPublicKey: String(row.clientEphemeralPublicKey),
|
|
2698
|
+
clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
|
|
2647
2699
|
clientName: typeof row.clientName === "string" ? row.clientName : void 0,
|
|
2648
2700
|
clientNonce: String(row.clientNonce),
|
|
2649
2701
|
expiresAt,
|
|
@@ -2665,6 +2717,7 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2665
2717
|
const now = Date.now();
|
|
2666
2718
|
await db.prepare(`INSERT INTO pending_pairings (
|
|
2667
2719
|
approval_code,
|
|
2720
|
+
client_session_id,
|
|
2668
2721
|
client_name,
|
|
2669
2722
|
client_ephemeral_public_key,
|
|
2670
2723
|
client_nonce,
|
|
@@ -2674,19 +2727,45 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2674
2727
|
created_at,
|
|
2675
2728
|
updated_at
|
|
2676
2729
|
)
|
|
2677
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
|
|
2730
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(pairing.approvalCode, pairing.clientSessionId ?? null, pairing.clientName ?? null, pairing.clientEphemeralPublicKey, pairing.clientNonce, pairing.serverUrl, pairing.approved ? 1 : 0, pairing.expiresAt, now, now);
|
|
2678
2731
|
},
|
|
2679
2732
|
async createSession(tokenHash, session) {
|
|
2680
2733
|
const now = Date.now();
|
|
2681
|
-
|
|
2682
|
-
|
|
2734
|
+
const secure = encodeSecureSession(session.secureSession);
|
|
2735
|
+
if (session.clientSessionId) {
|
|
2736
|
+
await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
|
|
2737
|
+
if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
|
|
2738
|
+
}
|
|
2739
|
+
await db.prepare(`INSERT INTO pairing_sessions (
|
|
2740
|
+
token_hash,
|
|
2741
|
+
client_session_id,
|
|
2742
|
+
client_name,
|
|
2743
|
+
expires_at,
|
|
2744
|
+
key_epoch,
|
|
2745
|
+
mobile_to_server_key,
|
|
2746
|
+
server_to_mobile_key,
|
|
2747
|
+
last_mobile_counter,
|
|
2748
|
+
next_server_counter,
|
|
2749
|
+
created_at,
|
|
2750
|
+
updated_at
|
|
2751
|
+
)
|
|
2752
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(tokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
|
|
2683
2753
|
return countActive(now);
|
|
2684
2754
|
},
|
|
2685
2755
|
deleteSession,
|
|
2686
2756
|
deletePendingPairing,
|
|
2687
2757
|
getPendingPairing,
|
|
2688
2758
|
async getValidSession(tokenHash, now) {
|
|
2689
|
-
const row = await db.prepare(
|
|
2759
|
+
const row = await db.prepare(`SELECT client_name AS clientName,
|
|
2760
|
+
client_session_id AS clientSessionId,
|
|
2761
|
+
expires_at AS expiresAt,
|
|
2762
|
+
key_epoch AS keyEpoch,
|
|
2763
|
+
mobile_to_server_key AS mobileToServerKey,
|
|
2764
|
+
server_to_mobile_key AS serverToMobileKey,
|
|
2765
|
+
last_mobile_counter AS lastMobileCounter,
|
|
2766
|
+
next_server_counter AS nextServerCounter
|
|
2767
|
+
FROM pairing_sessions
|
|
2768
|
+
WHERE token_hash = ?`).get(tokenHash);
|
|
2690
2769
|
if (!row) return;
|
|
2691
2770
|
const expiresAt = Number(row.expiresAt);
|
|
2692
2771
|
if (now > expiresAt) {
|
|
@@ -2694,8 +2773,10 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2694
2773
|
return;
|
|
2695
2774
|
}
|
|
2696
2775
|
return {
|
|
2776
|
+
clientSessionId: typeof row.clientSessionId === "string" ? row.clientSessionId : void 0,
|
|
2697
2777
|
clientName: typeof row.clientName === "string" ? row.clientName : void 0,
|
|
2698
|
-
expiresAt
|
|
2778
|
+
expiresAt,
|
|
2779
|
+
secureSession: decodeSecureSession(row)
|
|
2699
2780
|
};
|
|
2700
2781
|
},
|
|
2701
2782
|
async pruneExpired(now) {
|
|
@@ -2704,14 +2785,82 @@ async function createTursoPairingSessionStore(path) {
|
|
|
2704
2785
|
},
|
|
2705
2786
|
async rotateSession(oldTokenHash, newTokenHash, session) {
|
|
2706
2787
|
const now = Date.now();
|
|
2788
|
+
const secure = encodeSecureSession(session.secureSession);
|
|
2707
2789
|
await db.transaction(async () => {
|
|
2708
2790
|
await db.prepare("DELETE FROM pairing_sessions WHERE token_hash = ?").run(oldTokenHash);
|
|
2709
|
-
|
|
2710
|
-
|
|
2791
|
+
if (session.clientSessionId) {
|
|
2792
|
+
await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id = ?").run(session.clientSessionId);
|
|
2793
|
+
if (session.clientName) await db.prepare("DELETE FROM pairing_sessions WHERE client_session_id IS NULL AND client_name = ?").run(session.clientName);
|
|
2794
|
+
}
|
|
2795
|
+
await db.prepare(`INSERT INTO pairing_sessions (
|
|
2796
|
+
token_hash,
|
|
2797
|
+
client_session_id,
|
|
2798
|
+
client_name,
|
|
2799
|
+
expires_at,
|
|
2800
|
+
key_epoch,
|
|
2801
|
+
mobile_to_server_key,
|
|
2802
|
+
server_to_mobile_key,
|
|
2803
|
+
last_mobile_counter,
|
|
2804
|
+
next_server_counter,
|
|
2805
|
+
created_at,
|
|
2806
|
+
updated_at
|
|
2807
|
+
)
|
|
2808
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newTokenHash, session.clientSessionId ?? null, session.clientName ?? null, session.expiresAt, secure?.keyEpoch ?? null, secure?.mobileToServerKey ?? null, secure?.serverToMobileKey ?? null, secure?.lastMobileCounter ?? null, secure?.nextServerCounter ?? null, now, now);
|
|
2711
2809
|
})();
|
|
2712
2810
|
return countActive(now);
|
|
2811
|
+
},
|
|
2812
|
+
async updateSecureSession(tokenHash, secureSession) {
|
|
2813
|
+
const secure = encodeSecureSession(secureSession);
|
|
2814
|
+
const now = Date.now();
|
|
2815
|
+
await db.prepare(`UPDATE pairing_sessions
|
|
2816
|
+
SET key_epoch = ?,
|
|
2817
|
+
mobile_to_server_key = ?,
|
|
2818
|
+
server_to_mobile_key = ?,
|
|
2819
|
+
last_mobile_counter = ?,
|
|
2820
|
+
next_server_counter = ?,
|
|
2821
|
+
updated_at = ?
|
|
2822
|
+
WHERE token_hash = ?`).run(secure.keyEpoch, secure.mobileToServerKey, secure.serverToMobileKey, secure.lastMobileCounter, secure.nextServerCounter, now, tokenHash);
|
|
2713
2823
|
}
|
|
2714
2824
|
};
|
|
2825
|
+
async function ensurePairingSessionColumns() {
|
|
2826
|
+
const rows = await db.prepare("PRAGMA table_info(pairing_sessions)").all();
|
|
2827
|
+
const columns = new Set(resultRows(rows).map((row) => String(row.name)));
|
|
2828
|
+
for (const [column, sql] of [
|
|
2829
|
+
["client_session_id", "ALTER TABLE pairing_sessions ADD COLUMN client_session_id TEXT"],
|
|
2830
|
+
["key_epoch", "ALTER TABLE pairing_sessions ADD COLUMN key_epoch INTEGER"],
|
|
2831
|
+
["mobile_to_server_key", "ALTER TABLE pairing_sessions ADD COLUMN mobile_to_server_key TEXT"],
|
|
2832
|
+
["server_to_mobile_key", "ALTER TABLE pairing_sessions ADD COLUMN server_to_mobile_key TEXT"],
|
|
2833
|
+
["last_mobile_counter", "ALTER TABLE pairing_sessions ADD COLUMN last_mobile_counter INTEGER"],
|
|
2834
|
+
["next_server_counter", "ALTER TABLE pairing_sessions ADD COLUMN next_server_counter INTEGER"]
|
|
2835
|
+
]) if (!columns.has(column)) await db.exec(sql);
|
|
2836
|
+
const pendingRows = await db.prepare("PRAGMA table_info(pending_pairings)").all();
|
|
2837
|
+
if (!new Set(resultRows(pendingRows).map((row) => String(row.name))).has("client_session_id")) await db.exec("ALTER TABLE pending_pairings ADD COLUMN client_session_id TEXT");
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
function encodeSecureSession(session) {
|
|
2841
|
+
if (!session) return;
|
|
2842
|
+
return {
|
|
2843
|
+
keyEpoch: session.keyEpoch,
|
|
2844
|
+
lastMobileCounter: session.lastMobileCounter,
|
|
2845
|
+
mobileToServerKey: fromByteArray(session.mobileToServerKey),
|
|
2846
|
+
nextServerCounter: session.nextServerCounter,
|
|
2847
|
+
serverToMobileKey: fromByteArray(session.serverToMobileKey)
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
function decodeSecureSession(row) {
|
|
2851
|
+
if (typeof row.mobileToServerKey !== "string" || typeof row.serverToMobileKey !== "string" || row.keyEpoch === null || row.lastMobileCounter === null || row.nextServerCounter === null) return;
|
|
2852
|
+
return {
|
|
2853
|
+
keyEpoch: Number(row.keyEpoch),
|
|
2854
|
+
lastMobileCounter: Number(row.lastMobileCounter),
|
|
2855
|
+
mobileToServerKey: toByteArray(row.mobileToServerKey),
|
|
2856
|
+
nextServerCounter: Number(row.nextServerCounter),
|
|
2857
|
+
serverToMobileKey: toByteArray(row.serverToMobileKey)
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
function resultRows(result) {
|
|
2861
|
+
if (Array.isArray(result)) return result;
|
|
2862
|
+
if (result && typeof result === "object" && Array.isArray(result.rows)) return result.rows;
|
|
2863
|
+
return [];
|
|
2715
2864
|
}
|
|
2716
2865
|
//#endregion
|
|
2717
2866
|
//#region src/index.ts
|
|
@@ -2846,7 +2995,7 @@ async function writeBackgroundPid() {
|
|
|
2846
2995
|
await writeFile(path, `${process.pid}\n`, { mode: 384 });
|
|
2847
2996
|
}
|
|
2848
2997
|
function formatApprovalCommand(approvalCode, activePort) {
|
|
2849
|
-
return activePort === 8787 ? `npx codex-relay approve ${approvalCode}` : `PORT=${activePort} npx codex-relay approve ${approvalCode}`;
|
|
2998
|
+
return activePort === 8787 ? `npx codex-relay@latest approve ${approvalCode}` : `PORT=${activePort} npx codex-relay@latest approve ${approvalCode}`;
|
|
2850
2999
|
}
|
|
2851
3000
|
function getLocalNetworkConnectUrl(port) {
|
|
2852
3001
|
for (const addresses of Object.values(networkInterfaces())) for (const address of addresses ?? []) if (address.family === "IPv4" && !address.internal) return `http://${address.address}:${port}`;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-relay",
|
|
3
|
-
"version": "1.0.0",
|
|
3
|
+
"version": "1.0.1-beta.0",
|
|
4
4
|
"description": "Local Codex Relay CLI bridge for the Codex Relay mobile app.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/gronxb/
|
|
7
|
+
"url": "git+https://github.com/gronxb/codex-relay.git",
|
|
8
8
|
"directory": "apps/server"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|