@cjhyy/code-shell-server 0.9.52 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/cli.d.ts +23 -0
- package/dist/account/cli.js +249 -0
- package/dist/account/cloud-contract.d.ts +39 -0
- package/dist/account/cloud-contract.js +134 -0
- package/dist/account/cloud-types.d.ts +47 -0
- package/dist/account/cloud-types.js +1 -0
- package/dist/account/feature-service.d.ts +47 -0
- package/dist/account/feature-service.js +184 -0
- package/dist/account/http.d.ts +17 -0
- package/dist/account/http.js +158 -0
- package/dist/account/links.d.ts +13 -0
- package/dist/account/links.js +137 -0
- package/dist/account/manager.d.ts +96 -0
- package/dist/account/manager.js +976 -0
- package/dist/account/native-lock.d.ts +6 -0
- package/dist/account/native-lock.js +26 -0
- package/dist/account/native-store.d.ts +32 -0
- package/dist/account/native-store.js +162 -0
- package/dist/account/profile.d.ts +4 -0
- package/dist/account/profile.js +46 -0
- package/dist/account/store.d.ts +34 -0
- package/dist/account/store.js +118 -0
- package/dist/account/types.d.ts +138 -0
- package/dist/account/types.js +2 -0
- package/dist/desktop-web/http-api.d.ts +4 -0
- package/dist/desktop-web/http-api.js +12 -4
- package/dist/environment-identity.d.ts +1 -1
- package/dist/environment-identity.js +2 -1
- package/dist/headless-worker-env.d.ts +2 -0
- package/dist/headless-worker-env.js +15 -0
- package/dist/hub/approval-lease.js +6 -1
- package/dist/hub/run-replay.js +1 -1
- package/dist/hub/session-management.js +1 -1
- package/dist/index.account.d.ts +12 -0
- package/dist/index.account.js +12 -0
- package/dist/index.mobile-remote.d.ts +2 -0
- package/dist/index.mobile-remote.js +1 -0
- package/dist/index.worker.d.ts +1 -0
- package/dist/index.worker.js +1 -0
- package/dist/mobile-remote/account-relay-access.d.ts +59 -0
- package/dist/mobile-remote/account-relay-access.js +174 -0
- package/dist/mobile-remote/approval-clock.d.ts +3 -0
- package/dist/mobile-remote/approval-clock.js +15 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
- package/dist/mobile-remote/codex-room-agent.js +17 -1
- package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
- package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
- package/dist/mobile-remote/mobile-upload-service.js +25 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
- package/dist/mobile-remote/remote-host-manager.js +124 -17
- package/dist/mobile-remote/resident-agent.d.ts +14 -0
- package/dist/mobile-remote/resident-agent.js +38 -11
- package/dist/mobile-remote/room-history.d.ts +7 -0
- package/dist/mobile-remote/room-history.js +56 -1
- package/dist/mobile-remote/room-manager.d.ts +216 -9
- package/dist/mobile-remote/room-manager.js +700 -65
- package/dist/mobile-remote/room-process-stop.d.ts +15 -0
- package/dist/mobile-remote/room-process-stop.js +41 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
- package/dist/mobile-remote/trusted-device-store.js +54 -1
- package/dist/mobile-remote/turn-queue.d.ts +183 -0
- package/dist/mobile-remote/turn-queue.js +422 -0
- package/dist/mobile-remote/types.d.ts +1 -1
- package/dist/panels/agent-task-host.js +6 -12
- package/dist/panels/agent-task-service.d.ts +2 -0
- package/dist/panels/agent-task-service.js +7 -0
- package/dist/panels/browser-capture.d.ts +1 -15
- package/dist/panels/browser-capture.js +1 -35
- package/dist/panels/http.d.ts +15 -15
- package/dist/panels/management-http.d.ts +9 -9
- package/dist/panels/process-service.d.ts +5 -4
- package/dist/panels/process-service.js +54 -16
- package/dist/panels/runtime.js +25 -14
- package/dist/panels/tool-jobs-storage.d.ts +4 -0
- package/dist/panels/tool-jobs-storage.js +111 -34
- package/dist/panels/types.d.ts +1 -97
- package/dist/remote-relay/connector.d.ts +8 -0
- package/dist/remote-relay/connector.js +29 -2
- package/dist/remote-relay/protocol.d.ts +21 -0
- package/dist/remote-relay/protocol.js +31 -0
- package/dist/serve/cli.js +6 -0
- package/dist/serve/headless-server.js +24 -19
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +14 -1
- package/package.json +10 -6
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Desktop host clients that have no IPC peer in a headless worker. */
|
|
2
|
+
const HEADLESS_WORKER_HOST_ENV_KEYS = [
|
|
3
|
+
"CODE_SHELL_REMOTE_LINK_CLIENT_SECRET",
|
|
4
|
+
"CODE_SHELL_OPERATION_BATCH_ACCESS",
|
|
5
|
+
"CODE_SHELL_MODEL_REQUEST_SIGNING",
|
|
6
|
+
"CODE_SHELL_WORKSPACE_ACCESS",
|
|
7
|
+
"CODE_SHELL_HOST_MCP_SERVERS",
|
|
8
|
+
];
|
|
9
|
+
/** Preserve the caller's environment while disabling Desktop-only host access. */
|
|
10
|
+
export function headlessWorkerEnv(base, extra = {}) {
|
|
11
|
+
const env = { ...base };
|
|
12
|
+
for (const key of HEADLESS_WORKER_HOST_ENV_KEYS)
|
|
13
|
+
env[key] = undefined;
|
|
14
|
+
return { ...env, CODE_SHELL_CREDENTIAL_ACCESS: "local", ...extra };
|
|
15
|
+
}
|
|
@@ -9,8 +9,13 @@ export class ApprovalLeases {
|
|
|
9
9
|
this.now = now;
|
|
10
10
|
}
|
|
11
11
|
add(approval) {
|
|
12
|
-
|
|
12
|
+
const existing = this.entries.get(approval.requestId);
|
|
13
|
+
if (existing) {
|
|
14
|
+
// A republished request (the user kept a question open) replaces its
|
|
15
|
+
// payload for later snapshots; the holder lease is unchanged.
|
|
16
|
+
existing.approval = approval;
|
|
13
17
|
return;
|
|
18
|
+
}
|
|
14
19
|
if (this.entries.size >= 256)
|
|
15
20
|
throw new Error("too many pending approvals");
|
|
16
21
|
this.entries.set(approval.requestId, {
|
package/dist/hub/run-replay.js
CHANGED
|
@@ -2,7 +2,7 @@ import { SessionFileError, readSessionFileSync, sessionListStorage, } from "@cjh
|
|
|
2
2
|
import { listSessionEntries } from "@cjhyy/code-shell-core/internal";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { SessionManager } from "@cjhyy/code-shell-core";
|
|
5
|
-
import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web";
|
|
5
|
+
import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web/transcript-replay";
|
|
6
6
|
import { createSessionTitlesStore, SessionTitleConflictError } from "../session-titles-store.js";
|
|
7
7
|
import { hubJson } from "./uploads.js";
|
|
8
8
|
const ROOT = "/api/v1/sessions";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Optional native account authority. No Electron or renderer dependencies. */
|
|
2
|
+
export * from "./account/types.js";
|
|
3
|
+
export * from "./account/manager.js";
|
|
4
|
+
export * from "./account/http.js";
|
|
5
|
+
export * from "./account/store.js";
|
|
6
|
+
export * from "./account/links.js";
|
|
7
|
+
export * from "./account/profile.js";
|
|
8
|
+
export * from "./account/cloud-contract.js";
|
|
9
|
+
export * from "./account/cloud-types.js";
|
|
10
|
+
export * from "./account/feature-service.js";
|
|
11
|
+
export * from "./account/native-store.js";
|
|
12
|
+
export * from "./account/cli.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Optional native account authority. No Electron or renderer dependencies. */
|
|
2
|
+
export * from "./account/types.js";
|
|
3
|
+
export * from "./account/manager.js";
|
|
4
|
+
export * from "./account/http.js";
|
|
5
|
+
export * from "./account/store.js";
|
|
6
|
+
export * from "./account/links.js";
|
|
7
|
+
export * from "./account/profile.js";
|
|
8
|
+
export * from "./account/cloud-contract.js";
|
|
9
|
+
export * from "./account/cloud-types.js";
|
|
10
|
+
export * from "./account/feature-service.js";
|
|
11
|
+
export * from "./account/native-store.js";
|
|
12
|
+
export * from "./account/cli.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Mobile HTTP/WebSocket transport, rooms, uploads, pairing, and tunnels. */
|
|
2
2
|
export * from "./mobile-remote/access-passcode.js";
|
|
3
|
+
export type { AccountRelayAccessProvider, AccountRelayBinding, AccountRelayProject, } from "./mobile-remote/account-relay-access.js";
|
|
3
4
|
export * from "./mobile-remote/cloudflared-binary.js";
|
|
4
5
|
export * from "./mobile-remote/codex-parse.js";
|
|
5
6
|
export * from "./mobile-remote/codex-room-agent.js";
|
|
@@ -17,5 +18,6 @@ export * from "./mobile-remote/resident-agent.js";
|
|
|
17
18
|
export * from "./mobile-remote/room-manager.js";
|
|
18
19
|
export * from "./mobile-remote/trusted-device-store.js";
|
|
19
20
|
export * from "./mobile-remote/tunnel-manager.js";
|
|
21
|
+
export * from "./mobile-remote/turn-queue.js";
|
|
20
22
|
export * from "./mobile-remote/types.js";
|
|
21
23
|
export * from "./mobile-remote/viewer-identity.js";
|
|
@@ -17,5 +17,6 @@ export * from "./mobile-remote/resident-agent.js";
|
|
|
17
17
|
export * from "./mobile-remote/room-manager.js";
|
|
18
18
|
export * from "./mobile-remote/trusted-device-store.js";
|
|
19
19
|
export * from "./mobile-remote/tunnel-manager.js";
|
|
20
|
+
export * from "./mobile-remote/turn-queue.js";
|
|
20
21
|
export * from "./mobile-remote/types.js";
|
|
21
22
|
export * from "./mobile-remote/viewer-identity.js";
|
package/dist/index.worker.d.ts
CHANGED
package/dist/index.worker.js
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { TrustedDeviceStore } from "./trusted-device-store.js";
|
|
3
|
+
export interface AccountRelayBinding {
|
|
4
|
+
authorizationId: string;
|
|
5
|
+
accountId: string;
|
|
6
|
+
rootSessionId: string;
|
|
7
|
+
hostId: string;
|
|
8
|
+
environmentId: string;
|
|
9
|
+
leaseId: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface AccountRelayProject {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
/** Main owns device credentials and redeems only against its configured directory. */
|
|
18
|
+
export interface AccountRelayAccessProvider {
|
|
19
|
+
hostId: string;
|
|
20
|
+
environmentId: string;
|
|
21
|
+
accountId?: string;
|
|
22
|
+
redeem: (handoff: string) => Promise<AccountRelayBinding>;
|
|
23
|
+
listProjects: () => Promise<AccountRelayProject[]>;
|
|
24
|
+
}
|
|
25
|
+
export interface CapturedAccountRelayAccess {
|
|
26
|
+
deviceId: string;
|
|
27
|
+
isAuthorized: () => boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Independent entrance gate; an account credential is never a Host credential. */
|
|
30
|
+
export declare class AccountRelayAccessGate {
|
|
31
|
+
private readonly devices;
|
|
32
|
+
private readonly onRevoke;
|
|
33
|
+
private provider?;
|
|
34
|
+
private readonly grants;
|
|
35
|
+
private readonly consumed;
|
|
36
|
+
private readonly pending;
|
|
37
|
+
private generation;
|
|
38
|
+
constructor(devices: TrustedDeviceStore, onRevoke: (deviceId: string) => void);
|
|
39
|
+
setProvider(provider?: AccountRelayAccessProvider): void;
|
|
40
|
+
get available(): boolean;
|
|
41
|
+
clear(): void;
|
|
42
|
+
revokeDevice(deviceId: string): boolean;
|
|
43
|
+
private retire;
|
|
44
|
+
exchange(handoff: string): Promise<{
|
|
45
|
+
hostAccessToken: string;
|
|
46
|
+
deviceId: string;
|
|
47
|
+
secretHash: string;
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
hostId: string;
|
|
50
|
+
environmentId: string;
|
|
51
|
+
}>;
|
|
52
|
+
capture(request: IncomingMessage): CapturedAccountRelayAccess | undefined;
|
|
53
|
+
projects(access: CapturedAccountRelayAccess): Promise<{
|
|
54
|
+
hostId: string;
|
|
55
|
+
environmentId: string;
|
|
56
|
+
projects: AccountRelayProject[];
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
export declare function readRelayHandoff(request: IncomingMessage): Promise<string>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
/** Independent entrance gate; an account credential is never a Host credential. */
|
|
3
|
+
export class AccountRelayAccessGate {
|
|
4
|
+
devices;
|
|
5
|
+
onRevoke;
|
|
6
|
+
provider;
|
|
7
|
+
grants = new Map();
|
|
8
|
+
consumed = new Map();
|
|
9
|
+
pending = new Set();
|
|
10
|
+
generation = 0;
|
|
11
|
+
constructor(devices, onRevoke) {
|
|
12
|
+
this.devices = devices;
|
|
13
|
+
this.onRevoke = onRevoke;
|
|
14
|
+
}
|
|
15
|
+
setProvider(provider) {
|
|
16
|
+
this.clear();
|
|
17
|
+
this.provider = provider;
|
|
18
|
+
}
|
|
19
|
+
get available() {
|
|
20
|
+
return !!this.provider;
|
|
21
|
+
}
|
|
22
|
+
clear() {
|
|
23
|
+
this.generation++;
|
|
24
|
+
for (const token of this.grants.keys())
|
|
25
|
+
this.retire(token);
|
|
26
|
+
this.consumed.clear();
|
|
27
|
+
this.pending.clear();
|
|
28
|
+
}
|
|
29
|
+
revokeDevice(deviceId) {
|
|
30
|
+
let revoked = false;
|
|
31
|
+
for (const [token, grant] of this.grants)
|
|
32
|
+
if (grant.deviceId === deviceId) {
|
|
33
|
+
this.retire(token);
|
|
34
|
+
revoked = true;
|
|
35
|
+
}
|
|
36
|
+
return revoked;
|
|
37
|
+
}
|
|
38
|
+
retire(token) {
|
|
39
|
+
const grant = this.grants.get(token);
|
|
40
|
+
if (!grant)
|
|
41
|
+
return;
|
|
42
|
+
this.grants.delete(token);
|
|
43
|
+
clearTimeout(grant.timer);
|
|
44
|
+
this.devices.removeEphemeralDevice(grant.deviceId);
|
|
45
|
+
this.onRevoke(grant.deviceId);
|
|
46
|
+
}
|
|
47
|
+
async exchange(handoff) {
|
|
48
|
+
const provider = this.provider;
|
|
49
|
+
const generation = this.generation;
|
|
50
|
+
const digest = createHash("sha256").update(handoff).digest("hex");
|
|
51
|
+
for (const [key, expiresAt] of this.consumed)
|
|
52
|
+
if (expiresAt <= Date.now())
|
|
53
|
+
this.consumed.delete(key);
|
|
54
|
+
if (!provider ||
|
|
55
|
+
this.grants.size + this.pending.size >= 128 ||
|
|
56
|
+
this.pending.has(digest) ||
|
|
57
|
+
this.consumed.has(digest))
|
|
58
|
+
throw new Error("Account relay handoff is unavailable");
|
|
59
|
+
this.pending.add(digest);
|
|
60
|
+
try {
|
|
61
|
+
const binding = await provider.redeem(handoff);
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (this.provider !== provider ||
|
|
64
|
+
this.generation !== generation ||
|
|
65
|
+
!binding ||
|
|
66
|
+
typeof binding !== "object" ||
|
|
67
|
+
![
|
|
68
|
+
binding.authorizationId,
|
|
69
|
+
binding.accountId,
|
|
70
|
+
binding.rootSessionId,
|
|
71
|
+
binding.hostId,
|
|
72
|
+
binding.environmentId,
|
|
73
|
+
binding.leaseId,
|
|
74
|
+
].every((value) => typeof value === "string" &&
|
|
75
|
+
value.length > 0 &&
|
|
76
|
+
value.length <= 512 &&
|
|
77
|
+
!value.includes("\0")) ||
|
|
78
|
+
binding.hostId !== provider.hostId ||
|
|
79
|
+
binding.environmentId !== provider.environmentId ||
|
|
80
|
+
(provider.accountId !== undefined && binding.accountId !== provider.accountId) ||
|
|
81
|
+
!Number.isSafeInteger(binding.expiresAt) ||
|
|
82
|
+
binding.expiresAt <= now ||
|
|
83
|
+
binding.expiresAt > now + 10 * 60_000)
|
|
84
|
+
throw new Error("Account relay handoff target is invalid");
|
|
85
|
+
this.consumed.set(digest, binding.expiresAt);
|
|
86
|
+
const hostAccessToken = randomBytes(32).toString("base64url");
|
|
87
|
+
const secretHash = randomBytes(32).toString("base64url");
|
|
88
|
+
const device = this.devices.addEphemeralDevice({
|
|
89
|
+
name: "Account device session",
|
|
90
|
+
secretHash,
|
|
91
|
+
expiresAt: binding.expiresAt,
|
|
92
|
+
});
|
|
93
|
+
const timer = setTimeout(() => this.retire(hostAccessToken), binding.expiresAt - now);
|
|
94
|
+
timer.unref();
|
|
95
|
+
this.grants.set(hostAccessToken, {
|
|
96
|
+
provider,
|
|
97
|
+
deviceId: device.id,
|
|
98
|
+
expiresAt: binding.expiresAt,
|
|
99
|
+
timer,
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
hostAccessToken,
|
|
103
|
+
deviceId: device.id,
|
|
104
|
+
secretHash,
|
|
105
|
+
expiresAt: binding.expiresAt,
|
|
106
|
+
hostId: binding.hostId,
|
|
107
|
+
environmentId: binding.environmentId,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// A retired generation cannot release a newer redemption of the same handoff.
|
|
112
|
+
if (this.generation === generation)
|
|
113
|
+
this.pending.delete(digest);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
capture(request) {
|
|
117
|
+
const token = request.headers["x-codeshell-host-access"];
|
|
118
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
119
|
+
return;
|
|
120
|
+
const grant = this.grants.get(token);
|
|
121
|
+
if (!grant)
|
|
122
|
+
return;
|
|
123
|
+
const isAuthorized = () => this.provider === grant.provider &&
|
|
124
|
+
this.grants.get(token) === grant &&
|
|
125
|
+
this.devices.isEphemeralDevice(grant.deviceId) &&
|
|
126
|
+
grant.expiresAt > Date.now();
|
|
127
|
+
if (!isAuthorized()) {
|
|
128
|
+
this.retire(token);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
return { deviceId: grant.deviceId, isAuthorized };
|
|
132
|
+
}
|
|
133
|
+
async projects(access) {
|
|
134
|
+
const provider = this.provider;
|
|
135
|
+
if (!provider || !access.isAuthorized())
|
|
136
|
+
throw new Error("Account relay access expired");
|
|
137
|
+
const projects = await provider.listProjects();
|
|
138
|
+
if (this.provider !== provider || !access.isAuthorized())
|
|
139
|
+
throw new Error("Account relay access expired");
|
|
140
|
+
return { hostId: provider.hostId, environmentId: provider.environmentId, projects };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function readRelayHandoff(request) {
|
|
144
|
+
if (request.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
|
|
145
|
+
throw new Error("Invalid relay handoff");
|
|
146
|
+
const declared = Number(request.headers["content-length"] ?? 0);
|
|
147
|
+
if (!Number.isFinite(declared) || declared > 8192)
|
|
148
|
+
throw new Error("Invalid relay handoff");
|
|
149
|
+
const chunks = [];
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
const timeout = setTimeout(() => request.destroy(), 10_000);
|
|
152
|
+
timeout.unref();
|
|
153
|
+
try {
|
|
154
|
+
for await (const chunk of request) {
|
|
155
|
+
bytes += Buffer.byteLength(chunk);
|
|
156
|
+
if (bytes > 8192)
|
|
157
|
+
throw new Error("Invalid relay handoff");
|
|
158
|
+
chunks.push(Buffer.from(chunk));
|
|
159
|
+
}
|
|
160
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
161
|
+
if (!input ||
|
|
162
|
+
typeof input !== "object" ||
|
|
163
|
+
Array.isArray(input) ||
|
|
164
|
+
Object.keys(input).length !== 1 ||
|
|
165
|
+
!("handoff" in input) ||
|
|
166
|
+
typeof input.handoff !== "string" ||
|
|
167
|
+
!/^[A-Za-z0-9_-]{32,4096}$/.test(input.handoff))
|
|
168
|
+
throw new Error("Invalid relay handoff");
|
|
169
|
+
return input.handoff;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Timestamp at delivery, including reconnect replay, so a phone never compares
|
|
2
|
+
* a worker deadline directly with its own potentially skewed wall clock. */
|
|
3
|
+
export function stampApprovalClock(line, now = Date.now()) {
|
|
4
|
+
if (!line.includes("agent/approvalRequest"))
|
|
5
|
+
return line;
|
|
6
|
+
try {
|
|
7
|
+
const message = JSON.parse(line);
|
|
8
|
+
if (message?.method !== "agent/approvalRequest" || !Number.isFinite(message.params?.expiresAt))
|
|
9
|
+
return line;
|
|
10
|
+
return JSON.stringify({ ...message, serverTime: now });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return line;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -74,6 +74,8 @@ export interface CodexRoomAgentOptions {
|
|
|
74
74
|
export declare class CodexRoomAgent implements RoomAgent {
|
|
75
75
|
private readonly opts;
|
|
76
76
|
private child?;
|
|
77
|
+
/** Last turn process, kept so stopAndWait() can prove it exited. */
|
|
78
|
+
private lastChild?;
|
|
77
79
|
private threadId?;
|
|
78
80
|
private started;
|
|
79
81
|
constructor(opts: CodexRoomAgentOptions);
|
|
@@ -84,5 +86,9 @@ export declare class CodexRoomAgent implements RoomAgent {
|
|
|
84
86
|
* process is currently alive (turns are ephemeral). */
|
|
85
87
|
isRunning(): boolean;
|
|
86
88
|
stop(): void;
|
|
89
|
+
/** A turn's `codex exec` child may still be alive after turn_end was parsed. */
|
|
90
|
+
isBusy(): boolean;
|
|
91
|
+
/** Stop, then resolve true only once the last turn process has exited. */
|
|
92
|
+
stopAndWait(timeoutMs: number): Promise<boolean>;
|
|
87
93
|
}
|
|
88
94
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess, stopRoomProcessAndWait, } from "./room-process-stop.js";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
5
5
|
import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
|
|
@@ -85,6 +85,8 @@ export function sealEventOnExit(opts) {
|
|
|
85
85
|
export class CodexRoomAgent {
|
|
86
86
|
opts;
|
|
87
87
|
child;
|
|
88
|
+
/** Last turn process, kept so stopAndWait() can prove it exited. */
|
|
89
|
+
lastChild;
|
|
88
90
|
threadId;
|
|
89
91
|
started = false;
|
|
90
92
|
constructor(opts) {
|
|
@@ -109,6 +111,7 @@ export class CodexRoomAgent {
|
|
|
109
111
|
stdio: ["pipe", "pipe", "pipe"],
|
|
110
112
|
});
|
|
111
113
|
this.child = child;
|
|
114
|
+
this.lastChild = child;
|
|
112
115
|
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
113
116
|
releaseStdioAfterExit(child);
|
|
114
117
|
if (child.stdout) {
|
|
@@ -210,4 +213,17 @@ export class CodexRoomAgent {
|
|
|
210
213
|
if (child)
|
|
211
214
|
stopRoomProcess(child);
|
|
212
215
|
}
|
|
216
|
+
/** A turn's `codex exec` child may still be alive after turn_end was parsed. */
|
|
217
|
+
isBusy() {
|
|
218
|
+
return Boolean(this.child);
|
|
219
|
+
}
|
|
220
|
+
/** Stop, then resolve true only once the last turn process has exited. */
|
|
221
|
+
stopAndWait(timeoutMs) {
|
|
222
|
+
this.started = false;
|
|
223
|
+
const child = this.child ?? this.lastChild;
|
|
224
|
+
this.child = undefined;
|
|
225
|
+
if (!child)
|
|
226
|
+
return Promise.resolve(true);
|
|
227
|
+
return stopRoomProcessAndWait(child, timeoutMs);
|
|
228
|
+
}
|
|
213
229
|
}
|
|
@@ -44,6 +44,7 @@ export function parseMobileClientEvent(value) {
|
|
|
44
44
|
(typeof event.modelTraceSource === "string" &&
|
|
45
45
|
/^panel:[a-z][a-z0-9-]{0,63}$/.test(event.modelTraceSource))) &&
|
|
46
46
|
optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
|
|
47
|
+
optionalQueueFlag(event.queue) &&
|
|
47
48
|
optionalAttachments(event.attachments);
|
|
48
49
|
break;
|
|
49
50
|
case "attachment.upload.begin":
|
|
@@ -137,6 +138,14 @@ export function parseMobileClientEvent(value) {
|
|
|
137
138
|
optionalMember(event.scope, SCOPES) &&
|
|
138
139
|
optionalMember(event.pathScope, PATH_SCOPES);
|
|
139
140
|
break;
|
|
141
|
+
case "approval.keepWaiting":
|
|
142
|
+
valid =
|
|
143
|
+
boundedString(event.approvalId, MAX_ID_LENGTH) &&
|
|
144
|
+
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
145
|
+
optionalBoundedString(event.connectionId, MAX_ID_LENGTH) &&
|
|
146
|
+
(event.generation === undefined ||
|
|
147
|
+
(Number.isSafeInteger(event.generation) && event.generation >= 0));
|
|
148
|
+
break;
|
|
140
149
|
case "session.list":
|
|
141
150
|
valid =
|
|
142
151
|
optionalBoundedString(event.requestId, 128) &&
|
|
@@ -196,8 +205,23 @@ export function parseMobileClientEvent(value) {
|
|
|
196
205
|
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
197
206
|
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
198
207
|
optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
|
|
208
|
+
optionalQueueFlag(event.queue) &&
|
|
199
209
|
optionalAttachments(event.attachments);
|
|
200
210
|
break;
|
|
211
|
+
case "queue.sync":
|
|
212
|
+
case "queue.clear":
|
|
213
|
+
case "queue.resume":
|
|
214
|
+
valid =
|
|
215
|
+
queueTarget(event.target) &&
|
|
216
|
+
Object.keys(event).every((key) => key === "type" || key === "target");
|
|
217
|
+
break;
|
|
218
|
+
case "queue.cancel":
|
|
219
|
+
case "queue.retry":
|
|
220
|
+
valid =
|
|
221
|
+
queueTarget(event.target) &&
|
|
222
|
+
boundedString(event.entryId, MAX_ID_LENGTH) &&
|
|
223
|
+
Object.keys(event).every((key) => ["type", "target", "entryId"].includes(key));
|
|
224
|
+
break;
|
|
201
225
|
case "room.history":
|
|
202
226
|
valid =
|
|
203
227
|
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
@@ -227,7 +251,8 @@ export function parseMobileClientEvent(value) {
|
|
|
227
251
|
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
228
252
|
boundedPath(event.cwd) &&
|
|
229
253
|
member(event.mode, PERMISSION_MODES) &&
|
|
230
|
-
optionalMember(event.kind, KINDS)
|
|
254
|
+
optionalMember(event.kind, KINDS) &&
|
|
255
|
+
optionalBoundedString(event.requestId, MAX_ID_LENGTH);
|
|
231
256
|
break;
|
|
232
257
|
case "ccRoom.subscribeTranscript":
|
|
233
258
|
valid =
|
|
@@ -297,6 +322,20 @@ function optionalPositiveInteger(value) {
|
|
|
297
322
|
function isBoundedHistoryLimit(value) {
|
|
298
323
|
return isPositiveInteger(value) && value <= MAX_HISTORY_LIMIT;
|
|
299
324
|
}
|
|
325
|
+
function optionalQueueFlag(value) {
|
|
326
|
+
return value === undefined || value === true;
|
|
327
|
+
}
|
|
328
|
+
function queueTarget(value) {
|
|
329
|
+
if (!isRecord(value))
|
|
330
|
+
return false;
|
|
331
|
+
const keys = Object.keys(value);
|
|
332
|
+
if (value.kind === "session")
|
|
333
|
+
return (boundedTrimmedString(value.sessionId, MAX_ID_LENGTH) &&
|
|
334
|
+
keys.every((key) => key === "kind" || key === "sessionId"));
|
|
335
|
+
return (value.kind === "room" &&
|
|
336
|
+
boundedTrimmedString(value.roomId, MAX_ID_LENGTH) &&
|
|
337
|
+
keys.every((key) => key === "kind" || key === "roomId"));
|
|
338
|
+
}
|
|
300
339
|
function optionalAttachments(value) {
|
|
301
340
|
return (value === undefined ||
|
|
302
341
|
(Array.isArray(value) && value.length <= MAX_MOBILE_ATTACHMENTS && value.every(isAttachment)));
|
|
@@ -3,6 +3,8 @@ import type { MobileImageBase } from "./types.js";
|
|
|
3
3
|
export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
4
4
|
export declare const MOBILE_UPLOAD_TTL_MS: number;
|
|
5
5
|
export declare const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
|
|
6
|
+
/** Upper bound for an image waiting behind a long turn in the Host FIFO. */
|
|
7
|
+
export declare const MOBILE_UPLOAD_QUEUE_RETENTION_MS: number;
|
|
6
8
|
export declare const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
|
|
7
9
|
export interface MobileUploadServiceOptions {
|
|
8
10
|
rootDir: string;
|
|
@@ -43,6 +45,13 @@ export declare class MobileUploadService {
|
|
|
43
45
|
private receivePut;
|
|
44
46
|
/** Atomically move a ready upload into a request-owned lease. */
|
|
45
47
|
claim(deviceId: string, uploadId: string): ClaimedMobileUpload;
|
|
48
|
+
/**
|
|
49
|
+
* Keep a ready upload alive for a Host-queued turn that has not run yet. The
|
|
50
|
+
* extension is bounded; the per-device ticket cap still limits retained files.
|
|
51
|
+
*/
|
|
52
|
+
retain(deviceId: string, uploadId: string, durationMs?: number): void;
|
|
53
|
+
/** Drop an unclaimed upload of a cancelled queued turn immediately. */
|
|
54
|
+
discard(deviceId: string, uploadId: string): Promise<void>;
|
|
46
55
|
/** Release a failed staging/dispatch attempt for a bounded retry. */
|
|
47
56
|
release(deviceId: string, uploadId: string, claimId: string): Promise<void>;
|
|
48
57
|
/** Finalize a successful turn; only the active claim owner may consume it. */
|
|
@@ -8,6 +8,8 @@ import { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
|
8
8
|
export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
9
9
|
export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
|
10
10
|
export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
|
|
11
|
+
/** Upper bound for an image waiting behind a long turn in the Host FIFO. */
|
|
12
|
+
export const MOBILE_UPLOAD_QUEUE_RETENTION_MS = 12 * 60 * 60 * 1000;
|
|
11
13
|
export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
|
|
12
14
|
const IMAGE_MIMES = new Set([
|
|
13
15
|
"image/png",
|
|
@@ -286,6 +288,29 @@ export class MobileUploadService {
|
|
|
286
288
|
sha256: record.sha256,
|
|
287
289
|
};
|
|
288
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Keep a ready upload alive for a Host-queued turn that has not run yet. The
|
|
293
|
+
* extension is bounded; the per-device ticket cap still limits retained files.
|
|
294
|
+
*/
|
|
295
|
+
retain(deviceId, uploadId, durationMs = MOBILE_UPLOAD_QUEUE_RETENTION_MS) {
|
|
296
|
+
if (this.revokedDevices.has(deviceId))
|
|
297
|
+
throw new Error("upload device was revoked");
|
|
298
|
+
const record = this.uploads.get(uploadId);
|
|
299
|
+
if (!record || record.expiresAt <= this.now() || record.deviceId !== deviceId)
|
|
300
|
+
throw new Error("upload is not ready or no longer exists");
|
|
301
|
+
if (record.status !== "ready")
|
|
302
|
+
throw new Error("upload is already claimed or not ready");
|
|
303
|
+
const until = this.now() + Math.min(durationMs, MOBILE_UPLOAD_QUEUE_RETENTION_MS);
|
|
304
|
+
if (until > record.expiresAt)
|
|
305
|
+
record.expiresAt = until;
|
|
306
|
+
}
|
|
307
|
+
/** Drop an unclaimed upload of a cancelled queued turn immediately. */
|
|
308
|
+
async discard(deviceId, uploadId) {
|
|
309
|
+
const record = this.uploads.get(uploadId);
|
|
310
|
+
if (!record || record.deviceId !== deviceId || record.status === "claimed")
|
|
311
|
+
return;
|
|
312
|
+
await this.expire(uploadId, record);
|
|
313
|
+
}
|
|
289
314
|
/** Release a failed staging/dispatch attempt for a bounded retry. */
|
|
290
315
|
async release(deviceId, uploadId, claimId) {
|
|
291
316
|
const record = this.assertClaimOwner(deviceId, uploadId, claimId);
|
|
@@ -6,6 +6,7 @@ import type { MobileServerEvent } from "./types.js";
|
|
|
6
6
|
import type { MobileUploadService } from "./mobile-upload-service.js";
|
|
7
7
|
import type { DesktopWebHttpApi } from "../desktop-web/http-api.js";
|
|
8
8
|
import type { RelayLocalTarget } from "../remote-relay/connector.js";
|
|
9
|
+
import { type AccountRelayAccessProvider } from "./account-relay-access.js";
|
|
9
10
|
/**
|
|
10
11
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
11
12
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -22,11 +23,11 @@ export interface RemoteHostStartOptions {
|
|
|
22
23
|
* "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
|
|
23
24
|
* 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
|
|
24
25
|
* front of ordinary HTTP routes and the WS upgrade. Host-verified Panel asset
|
|
25
|
-
* capabilities use their own short-lived authorization. "relay"
|
|
26
|
-
*
|
|
26
|
+
* capabilities use their own short-lived authorization. "relay" requires a
|
|
27
|
+
* passcode or main-owned account handoff provider and a stable HTTPS origin.
|
|
27
28
|
*/
|
|
28
29
|
mode?: "lan" | "tunnel" | "relay";
|
|
29
|
-
/**
|
|
30
|
+
/** Public passcode gate; account handoffs may independently authorize relay mode. */
|
|
30
31
|
passcode?: AccessPasscode;
|
|
31
32
|
}
|
|
32
33
|
export interface RemoteHostStarted {
|
|
@@ -46,6 +47,8 @@ export interface RemoteHostManagerOptions {
|
|
|
46
47
|
historyView?: boolean;
|
|
47
48
|
/** The attached command Host resolves native text connections per authorized Session. */
|
|
48
49
|
nativeModelSelection?: boolean;
|
|
50
|
+
/** The attached command Host owns a pending-turn FIFO (queue.* events, chat.queued). */
|
|
51
|
+
turnQueue?: boolean;
|
|
49
52
|
onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
|
|
50
53
|
/**
|
|
51
54
|
* Absolute path to the built mobile app (out/mobile). Defaults to the
|
|
@@ -85,11 +88,23 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
85
88
|
private passcode?;
|
|
86
89
|
private socketAccessValidity;
|
|
87
90
|
private accessSweep?;
|
|
91
|
+
private readonly accountAccess;
|
|
92
|
+
private accountSocketDevices;
|
|
88
93
|
/** Where the built mobile app lives (out/mobile). */
|
|
89
94
|
private readonly mobileRootDir;
|
|
90
95
|
/** Dev proxy target for /mobile (HMR); undefined in prod. */
|
|
91
96
|
private readonly mobileDevUrl?;
|
|
97
|
+
private turnQueueEnabled;
|
|
92
98
|
constructor(opts: RemoteHostManagerOptions);
|
|
99
|
+
/** Install main-owned directory redemption; replacing it synchronously revokes old sessions. */
|
|
100
|
+
setAccountRelayAccess(provider?: AccountRelayAccessProvider): void;
|
|
101
|
+
/** The attached command Host installed a pending-turn FIFO; advertise it on (re)auth. */
|
|
102
|
+
advertiseTurnQueue(): void;
|
|
103
|
+
/** A paired (or live account) device that has not been revoked or removed. */
|
|
104
|
+
isTrustedDevice(deviceId: string): boolean;
|
|
105
|
+
private get turnQueueAdvertised();
|
|
106
|
+
private relayRequestOrigin;
|
|
107
|
+
private relayJson;
|
|
93
108
|
/** Device ids with at least one live socket right now. */
|
|
94
109
|
onlineDeviceIds(): string[];
|
|
95
110
|
/** Register a live socket for a device; emits `online-change` only when the
|
|
@@ -120,7 +135,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
120
135
|
* trusted-device store (which rejects revoked devices), so the remote host
|
|
121
136
|
* never executes tools itself — it only gates transport.
|
|
122
137
|
*/
|
|
123
|
-
handleClientEvent(input: unknown): MobileServerEvent | undefined;
|
|
138
|
+
handleClientEvent(input: unknown, accountDeviceId?: string): MobileServerEvent | undefined;
|
|
124
139
|
/** Broadcast a server event to every connected, authenticated mobile socket. */
|
|
125
140
|
broadcast(event: MobileServerEvent): void;
|
|
126
141
|
/** Send a server event to ONLY the sockets belonging to one device. Used for
|