@bitkyc08/opencodex 2.51.0 → 2.52.0-preview.20260912
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/gui/dist/assets/index-D_t6sCWs.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-D7BdZpZm.js +0 -115
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acceptRemoteControlClientHello,
|
|
3
|
+
parseRemoteControlClientHello,
|
|
4
|
+
serializeRemoteControlHello,
|
|
5
|
+
type RemoteControlCipher,
|
|
6
|
+
} from "./crypto";
|
|
7
|
+
import {
|
|
8
|
+
REMOTE_CONTROL_CAPABILITIES,
|
|
9
|
+
REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES,
|
|
10
|
+
REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE,
|
|
11
|
+
decodeRemoteControlApplicationFrame,
|
|
12
|
+
encodeRemoteControlApplicationFrame,
|
|
13
|
+
type RemoteControlApplicationFrame,
|
|
14
|
+
type RemoteControlCapability,
|
|
15
|
+
type RemoteControlCommandProfile,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
export interface RemoteControlTerminal {
|
|
19
|
+
write(value: Uint8Array): void | Promise<void>;
|
|
20
|
+
resize(columns: number, rows: number): void | Promise<void>;
|
|
21
|
+
close(): void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RemoteControlTerminalFactory {
|
|
25
|
+
create(options: {
|
|
26
|
+
commandProfile: RemoteControlCommandProfile;
|
|
27
|
+
onOutput(value: Uint8Array): void;
|
|
28
|
+
onExit(code: number): void;
|
|
29
|
+
}): RemoteControlTerminal | Promise<RemoteControlTerminal>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface HostSession {
|
|
33
|
+
commandProfile: RemoteControlCommandProfile;
|
|
34
|
+
capabilities: Set<RemoteControlCapability>;
|
|
35
|
+
cipher: RemoteControlCipher;
|
|
36
|
+
onCiphertext(value: Uint8Array): void;
|
|
37
|
+
terminal?: RemoteControlTerminal;
|
|
38
|
+
queue: Promise<void>;
|
|
39
|
+
closed: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RemoteControlHostOptions {
|
|
43
|
+
deviceId: string;
|
|
44
|
+
devicePrivateKey: string;
|
|
45
|
+
accountPublicKey: string;
|
|
46
|
+
terminalFactory: RemoteControlTerminalFactory;
|
|
47
|
+
allowedCapabilities?: readonly RemoteControlCapability[];
|
|
48
|
+
maxSessions?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Local execution boundary for the Paseo-style topology. The relay never owns
|
|
53
|
+
* this registry or a terminal. A successfully signed hello reserves only a
|
|
54
|
+
* session; the first authenticated input/resize frame starts the local process.
|
|
55
|
+
*/
|
|
56
|
+
export class RemoteControlHost {
|
|
57
|
+
private readonly sessions = new Map<string, HostSession>();
|
|
58
|
+
private readonly allowedCapabilities: readonly RemoteControlCapability[];
|
|
59
|
+
private readonly maxSessions: number;
|
|
60
|
+
|
|
61
|
+
constructor(private readonly options: RemoteControlHostOptions) {
|
|
62
|
+
this.allowedCapabilities = options.allowedCapabilities ?? REMOTE_CONTROL_CAPABILITIES;
|
|
63
|
+
this.maxSessions = options.maxSessions ?? REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE;
|
|
64
|
+
if (!Number.isSafeInteger(this.maxSessions) || this.maxSessions < 1) {
|
|
65
|
+
throw new Error("invalid remote control host session limit");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
open(sessionId: string, clientHelloPayload: Uint8Array, onCiphertext: (value: Uint8Array) => void): Uint8Array {
|
|
70
|
+
if (this.sessions.has(sessionId)) throw new Error("remote control host session already exists");
|
|
71
|
+
if (this.sessions.size >= this.maxSessions) throw new Error("remote control host session limit reached");
|
|
72
|
+
const clientHello = parseRemoteControlClientHello(clientHelloPayload);
|
|
73
|
+
const accepted = acceptRemoteControlClientHello(clientHello, {
|
|
74
|
+
expectedSessionId: sessionId,
|
|
75
|
+
expectedDeviceId: this.options.deviceId,
|
|
76
|
+
accountPublicKey: this.options.accountPublicKey,
|
|
77
|
+
devicePrivateKey: this.options.devicePrivateKey,
|
|
78
|
+
allowedCapabilities: this.allowedCapabilities,
|
|
79
|
+
});
|
|
80
|
+
this.sessions.set(sessionId, {
|
|
81
|
+
commandProfile: clientHello.commandProfile,
|
|
82
|
+
capabilities: new Set(accepted.hello.capabilities),
|
|
83
|
+
cipher: accepted.cipher,
|
|
84
|
+
onCiphertext,
|
|
85
|
+
queue: Promise.resolve(),
|
|
86
|
+
closed: false,
|
|
87
|
+
});
|
|
88
|
+
return serializeRemoteControlHello(accepted.hello);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
receive(sessionId: string, encryptedPayload: Uint8Array): Promise<void> {
|
|
92
|
+
const session = this.sessions.get(sessionId);
|
|
93
|
+
if (!session || session.closed) return Promise.reject(new Error("unknown remote control host session"));
|
|
94
|
+
const run = session.queue
|
|
95
|
+
.then(() => this.handleAuthenticatedFrame(sessionId, session, encryptedPayload))
|
|
96
|
+
.catch(async error => {
|
|
97
|
+
await this.close(sessionId);
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
session.queue = run.catch(() => undefined);
|
|
101
|
+
return run;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close(sessionId: string): Promise<void> {
|
|
105
|
+
const session = this.sessions.get(sessionId);
|
|
106
|
+
if (!session) return;
|
|
107
|
+
this.sessions.delete(sessionId);
|
|
108
|
+
session.closed = true;
|
|
109
|
+
session.cipher.destroy();
|
|
110
|
+
if (session.terminal) await session.terminal.close();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async handleAuthenticatedFrame(
|
|
114
|
+
sessionId: string,
|
|
115
|
+
session: HostSession,
|
|
116
|
+
encryptedPayload: Uint8Array,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
let frame: RemoteControlApplicationFrame;
|
|
119
|
+
try {
|
|
120
|
+
frame = decodeRemoteControlApplicationFrame(session.cipher.decrypt(encryptedPayload));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await this.close(sessionId);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (frame.kind !== "input" && frame.kind !== "resize") {
|
|
126
|
+
await this.close(sessionId);
|
|
127
|
+
throw new Error("remote control client sent a host-only terminal frame");
|
|
128
|
+
}
|
|
129
|
+
const required: RemoteControlCapability = frame.kind === "input" ? "terminal.input" : "terminal.resize";
|
|
130
|
+
if (!session.capabilities.has(required)) {
|
|
131
|
+
await this.close(sessionId);
|
|
132
|
+
throw new Error(`remote control capability ${required} was not granted`);
|
|
133
|
+
}
|
|
134
|
+
if (!session.terminal) {
|
|
135
|
+
const terminal = await this.startTerminal(sessionId, session);
|
|
136
|
+
if (session.closed) {
|
|
137
|
+
await terminal.close();
|
|
138
|
+
throw new Error("remote control host session closed while starting the terminal");
|
|
139
|
+
}
|
|
140
|
+
session.terminal = terminal;
|
|
141
|
+
}
|
|
142
|
+
if (frame.kind === "input") await session.terminal.write(frame.data);
|
|
143
|
+
else await session.terminal.resize(frame.columns, frame.rows);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async startTerminal(sessionId: string, session: HostSession): Promise<RemoteControlTerminal> {
|
|
147
|
+
return await this.options.terminalFactory.create({
|
|
148
|
+
commandProfile: session.commandProfile,
|
|
149
|
+
onOutput: value => {
|
|
150
|
+
if (session.closed || !session.capabilities.has("terminal.output")) return;
|
|
151
|
+
const maxChunk = REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 25;
|
|
152
|
+
for (let offset = 0; offset < value.byteLength; offset += maxChunk) {
|
|
153
|
+
this.emitApplicationFrame(sessionId, session, { kind: "output", data: value.subarray(offset, offset + maxChunk) });
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
onExit: code => {
|
|
157
|
+
if (session.closed) return;
|
|
158
|
+
this.emitApplicationFrame(sessionId, session, { kind: "exit", code });
|
|
159
|
+
void this.close(sessionId);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private emitApplicationFrame(
|
|
165
|
+
sessionId: string,
|
|
166
|
+
session: HostSession,
|
|
167
|
+
frame: RemoteControlApplicationFrame,
|
|
168
|
+
): void {
|
|
169
|
+
try {
|
|
170
|
+
session.onCiphertext(session.cipher.encrypt(encodeRemoteControlApplicationFrame(frame)));
|
|
171
|
+
} catch {
|
|
172
|
+
void this.close(sessionId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export {
|
|
2
|
+
REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
3
|
+
REMOTE_CONTROL_RELAY_HEADER_BYTES,
|
|
4
|
+
REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES,
|
|
5
|
+
REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE,
|
|
6
|
+
REMOTE_CONTROL_MAX_BUFFERED_BYTES,
|
|
7
|
+
REMOTE_CONTROL_COMMAND_PROFILES,
|
|
8
|
+
REMOTE_CONTROL_CAPABILITIES,
|
|
9
|
+
isRemoteControlUuid,
|
|
10
|
+
remoteControlUuidBytes,
|
|
11
|
+
isRemoteControlCommandProfile,
|
|
12
|
+
normalizeRemoteControlCapabilities,
|
|
13
|
+
encodeRemoteControlRelayFrame,
|
|
14
|
+
decodeRemoteControlRelayFrame,
|
|
15
|
+
encodeRemoteControlApplicationFrame,
|
|
16
|
+
decodeRemoteControlApplicationFrame,
|
|
17
|
+
} from "./protocol";
|
|
18
|
+
export type {
|
|
19
|
+
RemoteControlCommandProfile,
|
|
20
|
+
RemoteControlCapability,
|
|
21
|
+
RemoteControlClientHello,
|
|
22
|
+
RemoteControlHostHello,
|
|
23
|
+
RemoteControlRelayFrameKind,
|
|
24
|
+
RemoteControlRelayFrame,
|
|
25
|
+
RemoteControlApplicationFrame,
|
|
26
|
+
} from "./protocol";
|
|
27
|
+
export {
|
|
28
|
+
parseRemoteControlClientHello,
|
|
29
|
+
parseRemoteControlHostHello,
|
|
30
|
+
serializeRemoteControlHello,
|
|
31
|
+
generateRemoteControlIdentityKeyPair,
|
|
32
|
+
RemoteControlCipher,
|
|
33
|
+
RemoteControlClientHandshake,
|
|
34
|
+
acceptRemoteControlClientHello,
|
|
35
|
+
} from "./crypto";
|
|
36
|
+
export type {
|
|
37
|
+
RemoteControlIdentityKeyPair,
|
|
38
|
+
CreateRemoteControlClientHandshakeOptions,
|
|
39
|
+
AcceptRemoteControlClientHelloOptions,
|
|
40
|
+
} from "./crypto";
|
|
41
|
+
export {
|
|
42
|
+
RemoteControlHost,
|
|
43
|
+
} from "./host";
|
|
44
|
+
export type {
|
|
45
|
+
RemoteControlTerminal,
|
|
46
|
+
RemoteControlTerminalFactory,
|
|
47
|
+
RemoteControlHostOptions,
|
|
48
|
+
} from "./host";
|
|
49
|
+
export {
|
|
50
|
+
OpaqueRemoteControlRelay,
|
|
51
|
+
} from "./relay";
|
|
52
|
+
export type {
|
|
53
|
+
RemoteControlRelayPeer,
|
|
54
|
+
OpaqueRemoteControlRelayOptions,
|
|
55
|
+
} from "./relay";
|
|
56
|
+
export {
|
|
57
|
+
REMOTE_WORKSPACE_TOOL_NAMESPACE,
|
|
58
|
+
REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES,
|
|
59
|
+
REMOTE_WORKSPACE_CAPABILITIES,
|
|
60
|
+
REMOTE_WORKSPACE_DYNAMIC_TOOLS,
|
|
61
|
+
parseRemoteWorkspaceCapabilities,
|
|
62
|
+
remoteWorkspaceToolsForCapabilities,
|
|
63
|
+
remoteWorkspaceDynamicToolsForCapabilities,
|
|
64
|
+
remoteWorkspaceCapabilityForTool,
|
|
65
|
+
isRemoteWorkspaceCapability,
|
|
66
|
+
isRemoteWorkspaceToolName,
|
|
67
|
+
parseRemoteWorkspaceToolCall,
|
|
68
|
+
remoteWorkspaceDeveloperInstructions,
|
|
69
|
+
remoteWorkspaceCodexDeveloperInstructions,
|
|
70
|
+
} from "./workspace-tools";
|
|
71
|
+
export type {
|
|
72
|
+
RemoteWorkspaceCapability,
|
|
73
|
+
RemoteWorkspaceToolName,
|
|
74
|
+
RemoteWorkspaceDynamicToolFunction,
|
|
75
|
+
RemoteWorkspaceDynamicToolNamespace,
|
|
76
|
+
RemoteWorkspaceToolCallParams,
|
|
77
|
+
RemoteWorkspaceToolResult,
|
|
78
|
+
} from "./workspace-tools";
|
|
79
|
+
export {
|
|
80
|
+
REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION,
|
|
81
|
+
REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES,
|
|
82
|
+
isRemoteWorkspaceAgentProfile,
|
|
83
|
+
serializeRemoteWorkspaceHubMessage,
|
|
84
|
+
serializeRemoteWorkspaceAgentMessage,
|
|
85
|
+
parseRemoteWorkspaceHubMessage,
|
|
86
|
+
parseRemoteWorkspaceAgentMessage,
|
|
87
|
+
} from "./workspace-agent-protocol";
|
|
88
|
+
export type {
|
|
89
|
+
RemoteWorkspaceAgentProfile,
|
|
90
|
+
RemoteWorkspaceHubMessage,
|
|
91
|
+
RemoteWorkspaceAgentMessage,
|
|
92
|
+
} from "./workspace-agent-protocol";
|
|
93
|
+
export {
|
|
94
|
+
REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES,
|
|
95
|
+
frameRemoteWorkspaceRpcMessage,
|
|
96
|
+
RemoteWorkspaceRpcReassembler,
|
|
97
|
+
} from "./workspace-rpc-framing";
|
|
98
|
+
export {
|
|
99
|
+
truncateRemoteWorkspaceUtf8,
|
|
100
|
+
} from "./workspace-utf8";
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
export const REMOTE_CONTROL_PROTOCOL_VERSION = 1 as const;
|
|
2
|
+
export const REMOTE_CONTROL_RELAY_HEADER_BYTES = 18;
|
|
3
|
+
export const REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES = 64 * 1024;
|
|
4
|
+
export const REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE = 4;
|
|
5
|
+
export const REMOTE_CONTROL_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export const REMOTE_CONTROL_COMMAND_PROFILES = ["shell", "codex", "claude", "pi"] as const;
|
|
8
|
+
export type RemoteControlCommandProfile = typeof REMOTE_CONTROL_COMMAND_PROFILES[number];
|
|
9
|
+
|
|
10
|
+
export const REMOTE_CONTROL_CAPABILITIES = [
|
|
11
|
+
"terminal.input",
|
|
12
|
+
"terminal.output",
|
|
13
|
+
"terminal.resize",
|
|
14
|
+
"workspace.read",
|
|
15
|
+
"workspace.write",
|
|
16
|
+
"workspace.exec",
|
|
17
|
+
] as const;
|
|
18
|
+
export type RemoteControlCapability = typeof REMOTE_CONTROL_CAPABILITIES[number];
|
|
19
|
+
|
|
20
|
+
export interface RemoteControlClientHello {
|
|
21
|
+
version: typeof REMOTE_CONTROL_PROTOCOL_VERSION;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
deviceId: string;
|
|
24
|
+
commandProfile: RemoteControlCommandProfile;
|
|
25
|
+
capabilities: RemoteControlCapability[];
|
|
26
|
+
ephemeralPublicKey: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
signature: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RemoteControlHostHello {
|
|
32
|
+
version: typeof REMOTE_CONTROL_PROTOCOL_VERSION;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
deviceId: string;
|
|
35
|
+
capabilities: RemoteControlCapability[];
|
|
36
|
+
ephemeralPublicKey: string;
|
|
37
|
+
nonce: string;
|
|
38
|
+
signature: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RemoteControlRelayFrameKind = "open" | "data" | "close";
|
|
42
|
+
|
|
43
|
+
export interface RemoteControlRelayFrame {
|
|
44
|
+
kind: RemoteControlRelayFrameKind;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
payload: Uint8Array;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RemoteControlApplicationFrame =
|
|
50
|
+
| { kind: "input"; data: Uint8Array }
|
|
51
|
+
| { kind: "resize"; columns: number; rows: number }
|
|
52
|
+
| { kind: "output"; data: Uint8Array }
|
|
53
|
+
| { kind: "exit"; code: number };
|
|
54
|
+
|
|
55
|
+
const RELAY_KIND_TO_BYTE: Record<RemoteControlRelayFrameKind, number> = {
|
|
56
|
+
open: 1,
|
|
57
|
+
data: 2,
|
|
58
|
+
close: 3,
|
|
59
|
+
};
|
|
60
|
+
const RELAY_BYTE_TO_KIND = new Map<number, RemoteControlRelayFrameKind>([
|
|
61
|
+
[1, "open"],
|
|
62
|
+
[2, "data"],
|
|
63
|
+
[3, "close"],
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const APPLICATION_KIND_TO_BYTE = {
|
|
67
|
+
input: 1,
|
|
68
|
+
resize: 2,
|
|
69
|
+
output: 3,
|
|
70
|
+
exit: 4,
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
74
|
+
|
|
75
|
+
export function isRemoteControlUuid(value: unknown): value is string {
|
|
76
|
+
return typeof value === "string" && UUID_PATTERN.test(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function remoteControlUuidBytes(value: string): Uint8Array {
|
|
80
|
+
if (!isRemoteControlUuid(value)) throw new Error("invalid remote control UUID");
|
|
81
|
+
return Uint8Array.from(value.replaceAll("-", "").match(/.{2}/g)!, byte => Number.parseInt(byte, 16));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function remoteControlUuidString(value: Uint8Array): string {
|
|
85
|
+
if (value.byteLength !== 16) throw new Error("invalid remote control UUID bytes");
|
|
86
|
+
const hex = Buffer.from(value).toString("hex");
|
|
87
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isRemoteControlCommandProfile(value: unknown): value is RemoteControlCommandProfile {
|
|
91
|
+
return typeof value === "string" && (REMOTE_CONTROL_COMMAND_PROFILES as readonly string[]).includes(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function normalizeRemoteControlCapabilities(value: unknown): RemoteControlCapability[] {
|
|
95
|
+
if (!Array.isArray(value) || value.length > REMOTE_CONTROL_CAPABILITIES.length) {
|
|
96
|
+
throw new Error("invalid remote control capabilities");
|
|
97
|
+
}
|
|
98
|
+
const allowed = new Set<string>(REMOTE_CONTROL_CAPABILITIES);
|
|
99
|
+
const result = value.map(capability => {
|
|
100
|
+
if (typeof capability !== "string" || !allowed.has(capability)) {
|
|
101
|
+
throw new Error("invalid remote control capability");
|
|
102
|
+
}
|
|
103
|
+
return capability as RemoteControlCapability;
|
|
104
|
+
});
|
|
105
|
+
if (new Set(result).size !== result.length) throw new Error("duplicate remote control capability");
|
|
106
|
+
return result.sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function encodeRemoteControlRelayFrame(frame: RemoteControlRelayFrame): Uint8Array {
|
|
110
|
+
if (!isRemoteControlUuid(frame.sessionId)) throw new Error("invalid relay session ID");
|
|
111
|
+
if (!(frame.payload instanceof Uint8Array) || frame.payload.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES) {
|
|
112
|
+
throw new Error("remote control relay payload is too large");
|
|
113
|
+
}
|
|
114
|
+
const kind = RELAY_KIND_TO_BYTE[frame.kind];
|
|
115
|
+
if (!kind) throw new Error("invalid remote control relay frame kind");
|
|
116
|
+
const result = new Uint8Array(REMOTE_CONTROL_RELAY_HEADER_BYTES + frame.payload.byteLength);
|
|
117
|
+
result[0] = REMOTE_CONTROL_PROTOCOL_VERSION;
|
|
118
|
+
result[1] = kind;
|
|
119
|
+
result.set(remoteControlUuidBytes(frame.sessionId), 2);
|
|
120
|
+
result.set(frame.payload, REMOTE_CONTROL_RELAY_HEADER_BYTES);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function decodeRemoteControlRelayFrame(value: Uint8Array): RemoteControlRelayFrame {
|
|
125
|
+
if (
|
|
126
|
+
!(value instanceof Uint8Array)
|
|
127
|
+
|| value.byteLength < REMOTE_CONTROL_RELAY_HEADER_BYTES
|
|
128
|
+
|| value.byteLength > REMOTE_CONTROL_RELAY_HEADER_BYTES + REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES
|
|
129
|
+
) throw new Error("invalid remote control relay frame length");
|
|
130
|
+
if (value[0] !== REMOTE_CONTROL_PROTOCOL_VERSION) throw new Error("unsupported remote control relay protocol");
|
|
131
|
+
const kind = RELAY_BYTE_TO_KIND.get(value[1]!);
|
|
132
|
+
if (!kind) throw new Error("invalid remote control relay frame kind");
|
|
133
|
+
return {
|
|
134
|
+
kind,
|
|
135
|
+
sessionId: remoteControlUuidString(value.subarray(2, REMOTE_CONTROL_RELAY_HEADER_BYTES)),
|
|
136
|
+
payload: value.slice(REMOTE_CONTROL_RELAY_HEADER_BYTES),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validTerminalDimension(value: number): boolean {
|
|
141
|
+
return Number.isInteger(value) && value >= 1 && value <= 4096;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function encodeRemoteControlApplicationFrame(frame: RemoteControlApplicationFrame): Uint8Array {
|
|
145
|
+
if (frame.kind === "input" || frame.kind === "output") {
|
|
146
|
+
if (!(frame.data instanceof Uint8Array) || frame.data.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 25) {
|
|
147
|
+
throw new Error("remote control terminal payload is too large");
|
|
148
|
+
}
|
|
149
|
+
const result = new Uint8Array(1 + frame.data.byteLength);
|
|
150
|
+
result[0] = APPLICATION_KIND_TO_BYTE[frame.kind];
|
|
151
|
+
result.set(frame.data, 1);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
if (frame.kind === "resize") {
|
|
155
|
+
if (!validTerminalDimension(frame.columns) || !validTerminalDimension(frame.rows)) {
|
|
156
|
+
throw new Error("invalid remote control terminal size");
|
|
157
|
+
}
|
|
158
|
+
const result = new Uint8Array(5);
|
|
159
|
+
result[0] = APPLICATION_KIND_TO_BYTE.resize;
|
|
160
|
+
const view = new DataView(result.buffer);
|
|
161
|
+
view.setUint16(1, frame.columns, false);
|
|
162
|
+
view.setUint16(3, frame.rows, false);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
if (!Number.isInteger(frame.code) || frame.code < -0x80000000 || frame.code > 0x7fffffff) {
|
|
166
|
+
throw new Error("invalid remote control terminal exit code");
|
|
167
|
+
}
|
|
168
|
+
const result = new Uint8Array(5);
|
|
169
|
+
result[0] = APPLICATION_KIND_TO_BYTE.exit;
|
|
170
|
+
new DataView(result.buffer).setInt32(1, frame.code, false);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function decodeRemoteControlApplicationFrame(value: Uint8Array): RemoteControlApplicationFrame {
|
|
175
|
+
if (!(value instanceof Uint8Array) || value.byteLength < 1) throw new Error("empty remote control application frame");
|
|
176
|
+
if (value[0] === APPLICATION_KIND_TO_BYTE.input || value[0] === APPLICATION_KIND_TO_BYTE.output) {
|
|
177
|
+
if (value.byteLength > REMOTE_CONTROL_MAX_RELAY_PAYLOAD_BYTES - 24) {
|
|
178
|
+
throw new Error("remote control terminal payload is too large");
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: value[0] === APPLICATION_KIND_TO_BYTE.input ? "input" : "output",
|
|
182
|
+
data: value.slice(1),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (value[0] === APPLICATION_KIND_TO_BYTE.resize) {
|
|
186
|
+
if (value.byteLength !== 5) throw new Error("invalid remote control resize frame");
|
|
187
|
+
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
|
|
188
|
+
const columns = view.getUint16(1, false);
|
|
189
|
+
const rows = view.getUint16(3, false);
|
|
190
|
+
if (!validTerminalDimension(columns) || !validTerminalDimension(rows)) {
|
|
191
|
+
throw new Error("invalid remote control terminal size");
|
|
192
|
+
}
|
|
193
|
+
return { kind: "resize", columns, rows };
|
|
194
|
+
}
|
|
195
|
+
if (value[0] === APPLICATION_KIND_TO_BYTE.exit) {
|
|
196
|
+
if (value.byteLength !== 5) throw new Error("invalid remote control exit frame");
|
|
197
|
+
return { kind: "exit", code: new DataView(value.buffer, value.byteOffset, value.byteLength).getInt32(1, false) };
|
|
198
|
+
}
|
|
199
|
+
throw new Error("unknown remote control application frame");
|
|
200
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
REMOTE_CONTROL_MAX_BUFFERED_BYTES,
|
|
3
|
+
REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE,
|
|
4
|
+
decodeRemoteControlRelayFrame,
|
|
5
|
+
encodeRemoteControlRelayFrame,
|
|
6
|
+
isRemoteControlUuid,
|
|
7
|
+
type RemoteControlRelayFrameKind,
|
|
8
|
+
} from "./protocol";
|
|
9
|
+
|
|
10
|
+
export interface RemoteControlRelayPeer {
|
|
11
|
+
bufferedAmount(): number;
|
|
12
|
+
send(value: Uint8Array): void;
|
|
13
|
+
close(code: number, reason: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RelaySession {
|
|
17
|
+
deviceId: string;
|
|
18
|
+
client: RemoteControlRelayPeer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpaqueRemoteControlRelayOptions {
|
|
22
|
+
maxBufferedBytes?: number;
|
|
23
|
+
maxSessionsPerDevice?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Routes bounded envelopes without decoding the handshake or application payload.
|
|
28
|
+
* Account/session authorization belongs at the HTTP/WebSocket upgrade boundary;
|
|
29
|
+
* this class begins only after that control-plane decision has succeeded.
|
|
30
|
+
*/
|
|
31
|
+
export class OpaqueRemoteControlRelay {
|
|
32
|
+
private readonly hosts = new Map<string, RemoteControlRelayPeer>();
|
|
33
|
+
private readonly sessions = new Map<string, RelaySession>();
|
|
34
|
+
private readonly maxBufferedBytes: number;
|
|
35
|
+
private readonly maxSessionsPerDevice: number;
|
|
36
|
+
|
|
37
|
+
constructor(options: OpaqueRemoteControlRelayOptions = {}) {
|
|
38
|
+
this.maxBufferedBytes = options.maxBufferedBytes ?? REMOTE_CONTROL_MAX_BUFFERED_BYTES;
|
|
39
|
+
this.maxSessionsPerDevice = options.maxSessionsPerDevice ?? REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE;
|
|
40
|
+
if (!Number.isSafeInteger(this.maxBufferedBytes) || this.maxBufferedBytes < 1) {
|
|
41
|
+
throw new Error("invalid remote control relay buffer limit");
|
|
42
|
+
}
|
|
43
|
+
if (!Number.isSafeInteger(this.maxSessionsPerDevice) || this.maxSessionsPerDevice < 1) {
|
|
44
|
+
throw new Error("invalid remote control relay session limit");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
registerHost(deviceId: string, host: RemoteControlRelayPeer): () => void {
|
|
49
|
+
if (!isRemoteControlUuid(deviceId)) throw new Error("invalid remote control device ID");
|
|
50
|
+
const previous = this.hosts.get(deviceId);
|
|
51
|
+
if (previous && previous !== host) {
|
|
52
|
+
previous.close(1012, "remote control host reconnected");
|
|
53
|
+
this.closeDeviceSessions(deviceId, 1012, "remote control host reconnected");
|
|
54
|
+
}
|
|
55
|
+
this.hosts.set(deviceId, host);
|
|
56
|
+
return () => {
|
|
57
|
+
if (this.hosts.get(deviceId) !== host) return;
|
|
58
|
+
this.hosts.delete(deviceId);
|
|
59
|
+
this.closeDeviceSessions(deviceId, 1013, "remote control host disconnected");
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
attachClient(options: {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
deviceId: string;
|
|
66
|
+
client: RemoteControlRelayPeer;
|
|
67
|
+
openPayload: Uint8Array;
|
|
68
|
+
}): () => void {
|
|
69
|
+
if (this.sessions.has(options.sessionId)) throw new Error("remote control session is already attached");
|
|
70
|
+
const host = this.hosts.get(options.deviceId);
|
|
71
|
+
if (!host) throw new Error("remote control host is offline");
|
|
72
|
+
const active = [...this.sessions.values()].filter(session => session.deviceId === options.deviceId).length;
|
|
73
|
+
if (active >= this.maxSessionsPerDevice) throw new Error("remote control device session limit reached");
|
|
74
|
+
const session: RelaySession = { deviceId: options.deviceId, client: options.client };
|
|
75
|
+
this.sessions.set(options.sessionId, session);
|
|
76
|
+
try {
|
|
77
|
+
this.forward(host, "open", options.sessionId, options.openPayload);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
this.sessions.delete(options.sessionId);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return () => this.detachClient(options.sessionId, options.client);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
receiveFromClient(sessionId: string, client: RemoteControlRelayPeer, payload: Uint8Array): void {
|
|
86
|
+
const session = this.sessions.get(sessionId);
|
|
87
|
+
if (!session || session.client !== client) throw new Error("unknown remote control client session");
|
|
88
|
+
const host = this.hosts.get(session.deviceId);
|
|
89
|
+
if (!host) {
|
|
90
|
+
this.sessions.delete(sessionId);
|
|
91
|
+
client.close(1013, "remote control host is offline");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
this.forward(host, "data", sessionId, payload);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.sessions.delete(sessionId);
|
|
98
|
+
client.close(1013, "remote control host is backpressured");
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
receiveFromHost(deviceId: string, host: RemoteControlRelayPeer, encoded: Uint8Array): void {
|
|
104
|
+
if (this.hosts.get(deviceId) !== host) throw new Error("unknown remote control host");
|
|
105
|
+
const frame = decodeRemoteControlRelayFrame(encoded);
|
|
106
|
+
if (frame.kind === "open") throw new Error("remote control host cannot open browser sessions");
|
|
107
|
+
const session = this.sessions.get(frame.sessionId);
|
|
108
|
+
if (!session) return;
|
|
109
|
+
if (session.deviceId !== deviceId) {
|
|
110
|
+
host.close(1008, "remote control session ownership mismatch");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (frame.kind === "close") {
|
|
114
|
+
this.sessions.delete(frame.sessionId);
|
|
115
|
+
session.client.close(1000, "remote control host closed the session");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (session.client.bufferedAmount() > this.maxBufferedBytes) {
|
|
119
|
+
this.sessions.delete(frame.sessionId);
|
|
120
|
+
session.client.close(1013, "remote control relay backpressure");
|
|
121
|
+
this.forward(host, "close", frame.sessionId, new Uint8Array());
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Intentionally forward the payload byte-for-byte. Only the endpoints parse it.
|
|
125
|
+
session.client.send(frame.payload);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private detachClient(sessionId: string, client: RemoteControlRelayPeer): void {
|
|
129
|
+
const session = this.sessions.get(sessionId);
|
|
130
|
+
if (!session || session.client !== client) return;
|
|
131
|
+
this.sessions.delete(sessionId);
|
|
132
|
+
const host = this.hosts.get(session.deviceId);
|
|
133
|
+
if (host) {
|
|
134
|
+
try {
|
|
135
|
+
this.forward(host, "close", sessionId, new Uint8Array());
|
|
136
|
+
} catch {
|
|
137
|
+
// The client is already detached; the host transport owns its own close path.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private closeDeviceSessions(deviceId: string, code: number, reason: string): void {
|
|
143
|
+
for (const [sessionId, session] of this.sessions) {
|
|
144
|
+
if (session.deviceId !== deviceId) continue;
|
|
145
|
+
this.sessions.delete(sessionId);
|
|
146
|
+
session.client.close(code, reason);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private forward(
|
|
151
|
+
target: RemoteControlRelayPeer,
|
|
152
|
+
kind: RemoteControlRelayFrameKind,
|
|
153
|
+
sessionId: string,
|
|
154
|
+
payload: Uint8Array,
|
|
155
|
+
): void {
|
|
156
|
+
if (target.bufferedAmount() > this.maxBufferedBytes) {
|
|
157
|
+
target.close(1013, "remote control relay backpressure");
|
|
158
|
+
throw new Error("remote control relay target is backpressured");
|
|
159
|
+
}
|
|
160
|
+
target.send(encodeRemoteControlRelayFrame({ kind, sessionId, payload }));
|
|
161
|
+
}
|
|
162
|
+
}
|