@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createPrivateKey,
|
|
4
|
+
createPublicKey,
|
|
5
|
+
randomBytes,
|
|
6
|
+
randomUUID,
|
|
7
|
+
sign,
|
|
8
|
+
timingSafeEqual,
|
|
9
|
+
verify,
|
|
10
|
+
} from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { atomicWriteFile } from "../config/atomic-write";
|
|
14
|
+
import { getConfigDir } from "../config/paths";
|
|
15
|
+
import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store";
|
|
16
|
+
import {
|
|
17
|
+
generateRemoteControlIdentityKeyPair,
|
|
18
|
+
type RemoteControlIdentityKeyPair,
|
|
19
|
+
} from "./crypto";
|
|
20
|
+
import type { RemoteWorkspaceHubAgentConnection } from "./workspace-agent-connection";
|
|
21
|
+
import {
|
|
22
|
+
parseRemoteWorkspaceCapabilities,
|
|
23
|
+
type RemoteWorkspaceCapability,
|
|
24
|
+
} from "./workspace-tools";
|
|
25
|
+
|
|
26
|
+
export const REMOTE_WORKSPACE_HUB_STATE_VERSION = 1 as const;
|
|
27
|
+
export const REMOTE_WORKSPACE_MAX_DEVICES = 32;
|
|
28
|
+
export const REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE = 32;
|
|
29
|
+
const PAIRING_LIFETIME_MS = 10 * 60_000;
|
|
30
|
+
const MAX_PAIRING_GRANTS = 16;
|
|
31
|
+
const MAX_HUB_STATE_BYTES = 1024 * 1024;
|
|
32
|
+
const TOKEN_PREFIX = "ocxrw_";
|
|
33
|
+
const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
34
|
+
const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000;
|
|
35
|
+
const PAIRING_SOURCE_FAILURE_LIMIT = 10;
|
|
36
|
+
const PAIRING_SOURCE_LIMIT = 1_024;
|
|
37
|
+
|
|
38
|
+
export interface RemoteWorkspaceRootAdvertisement {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RemoteWorkspaceStoredDevice {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
platform: string;
|
|
47
|
+
publicKey: string;
|
|
48
|
+
tokenHash: string;
|
|
49
|
+
capabilities: RemoteWorkspaceCapability[];
|
|
50
|
+
roots: RemoteWorkspaceRootAdvertisement[];
|
|
51
|
+
createdAt: string;
|
|
52
|
+
lastSeenAt: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RemoteWorkspaceHubState {
|
|
56
|
+
version: typeof REMOTE_WORKSPACE_HUB_STATE_VERSION;
|
|
57
|
+
identity: RemoteControlIdentityKeyPair;
|
|
58
|
+
devices: RemoteWorkspaceStoredDevice[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RemoteWorkspaceHubStateStore {
|
|
62
|
+
load(): RemoteWorkspaceHubState | null;
|
|
63
|
+
save(state: RemoteWorkspaceHubState): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RemoteWorkspacePublicDevice {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
platform: string;
|
|
70
|
+
capabilities: RemoteWorkspaceCapability[];
|
|
71
|
+
roots: RemoteWorkspaceRootAdvertisement[];
|
|
72
|
+
online: boolean;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
lastSeenAt: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RemoteWorkspacePairingGrant {
|
|
78
|
+
code: string;
|
|
79
|
+
expiresAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RemoteWorkspacePairDeviceInput {
|
|
83
|
+
code: string;
|
|
84
|
+
name: string;
|
|
85
|
+
platform: string;
|
|
86
|
+
publicKey: string;
|
|
87
|
+
capabilities?: RemoteWorkspaceCapability[];
|
|
88
|
+
roots: RemoteWorkspaceRootAdvertisement[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RemoteWorkspacePairDeviceResult {
|
|
92
|
+
device: RemoteWorkspacePublicDevice;
|
|
93
|
+
deviceToken: string;
|
|
94
|
+
hubPublicKey: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PendingPairingGrant {
|
|
98
|
+
hash: Buffer;
|
|
99
|
+
expiresAt: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface PairingSourceFailureRecord {
|
|
103
|
+
failures: number;
|
|
104
|
+
windowStartedAt: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class RemoteWorkspacePairingRateLimitError extends Error {
|
|
108
|
+
constructor(
|
|
109
|
+
readonly retryAfterSeconds: number,
|
|
110
|
+
readonly reason: "source" | "capacity",
|
|
111
|
+
) {
|
|
112
|
+
super("remote workspace pairing rate limit exceeded");
|
|
113
|
+
this.name = "RemoteWorkspacePairingRateLimitError";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sha256(value: string): Buffer {
|
|
118
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function encodeHash(value: Buffer): string {
|
|
122
|
+
return value.toString("base64url");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseHash(value: unknown): Buffer {
|
|
126
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error("invalid remote workspace token hash");
|
|
127
|
+
const decoded = Buffer.from(value, "base64url");
|
|
128
|
+
if (decoded.byteLength !== 32) throw new Error("invalid remote workspace token hash");
|
|
129
|
+
return decoded;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeCode(value: string): string {
|
|
133
|
+
return value.replace(/[\s-]/g, "").toUpperCase();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function newPairingCode(): string {
|
|
137
|
+
const bytes = randomBytes(12);
|
|
138
|
+
let code = "";
|
|
139
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
140
|
+
code += PAIRING_ALPHABET[bytes[index]! % PAIRING_ALPHABET.length];
|
|
141
|
+
}
|
|
142
|
+
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function boundedText(value: unknown, label: string, max: number): string {
|
|
146
|
+
if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`);
|
|
147
|
+
const normalized = value.trim();
|
|
148
|
+
if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) {
|
|
149
|
+
throw new Error(`invalid remote workspace ${label}`);
|
|
150
|
+
}
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function objectRecord(value: unknown): Record<string, unknown> {
|
|
155
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
156
|
+
throw new Error("invalid remote workspace device metadata");
|
|
157
|
+
}
|
|
158
|
+
return value as Record<string, unknown>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function exactPairingFields(value: Record<string, unknown>): void {
|
|
162
|
+
const required = ["code", "name", "platform", "publicKey", "roots"] as const;
|
|
163
|
+
const allowed = new Set<string>([...required, "capabilities"]);
|
|
164
|
+
if (required.some(key => !Object.hasOwn(value, key))
|
|
165
|
+
|| Object.keys(value).some(key => !allowed.has(key))) {
|
|
166
|
+
throw new Error("invalid remote workspace device metadata");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validUuid(value: unknown, label: string): string {
|
|
171
|
+
const normalized = boundedText(value, label, 64);
|
|
172
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) {
|
|
173
|
+
throw new Error(`invalid remote workspace ${label}`);
|
|
174
|
+
}
|
|
175
|
+
return normalized;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validatePublicKey(value: unknown): string {
|
|
179
|
+
const encoded = boundedText(value, "device public key", 1024);
|
|
180
|
+
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error("invalid remote workspace device public key");
|
|
181
|
+
const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" });
|
|
182
|
+
if (key.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519");
|
|
183
|
+
return encoded;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateIdentity(value: unknown): RemoteControlIdentityKeyPair {
|
|
187
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub identity");
|
|
188
|
+
const raw = value as Record<string, unknown>;
|
|
189
|
+
const publicKey = validatePublicKey(raw.publicKey);
|
|
190
|
+
const privateKey = boundedText(raw.privateKey, "hub private key", 2048);
|
|
191
|
+
const privateDer = Buffer.from(privateKey, "base64url");
|
|
192
|
+
const parsed = createPrivateKey({ key: privateDer, type: "pkcs8", format: "der" });
|
|
193
|
+
if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote workspace hub key must use Ed25519");
|
|
194
|
+
const challenge = Buffer.from("opencodex remote workspace hub identity v1", "utf8");
|
|
195
|
+
const signature = sign(null, challenge, parsed);
|
|
196
|
+
const verifier = createPublicKey({ key: Buffer.from(publicKey, "base64url"), type: "spki", format: "der" });
|
|
197
|
+
if (!verify(null, challenge, verifier, signature)) {
|
|
198
|
+
throw new Error("remote workspace hub identity key pair does not match");
|
|
199
|
+
}
|
|
200
|
+
return { publicKey, privateKey };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateRoots(value: unknown): RemoteWorkspaceRootAdvertisement[] {
|
|
204
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE) {
|
|
205
|
+
throw new Error("remote workspace device needs one to 32 roots");
|
|
206
|
+
}
|
|
207
|
+
const ids = new Set<string>();
|
|
208
|
+
const labels = new Set<string>();
|
|
209
|
+
return value.map(item => {
|
|
210
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace root");
|
|
211
|
+
const raw = item as Record<string, unknown>;
|
|
212
|
+
const id = validUuid(raw.id, "root ID");
|
|
213
|
+
const label = boundedText(raw.label, "root label", 80);
|
|
214
|
+
const folded = label.toLocaleLowerCase("en-US");
|
|
215
|
+
if (ids.has(id) || labels.has(folded)) throw new Error("duplicate remote workspace root");
|
|
216
|
+
ids.add(id);
|
|
217
|
+
labels.add(folded);
|
|
218
|
+
return { id, label };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateDate(value: unknown, nullable = false): string | null {
|
|
223
|
+
if (nullable && value === null) return null;
|
|
224
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("invalid remote workspace timestamp");
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parseRemoteWorkspaceHubState(value: unknown): RemoteWorkspaceHubState {
|
|
229
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub state");
|
|
230
|
+
const raw = value as Record<string, unknown>;
|
|
231
|
+
if (raw.version !== REMOTE_WORKSPACE_HUB_STATE_VERSION || !Array.isArray(raw.devices)) {
|
|
232
|
+
throw new Error("unsupported remote workspace hub state");
|
|
233
|
+
}
|
|
234
|
+
if (raw.devices.length > REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit exceeded");
|
|
235
|
+
const ids = new Set<string>();
|
|
236
|
+
const names = new Set<string>();
|
|
237
|
+
const devices = raw.devices.map(item => {
|
|
238
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device state");
|
|
239
|
+
const device = item as Record<string, unknown>;
|
|
240
|
+
const id = validUuid(device.id, "device ID");
|
|
241
|
+
const name = boundedText(device.name, "device name", 80);
|
|
242
|
+
const folded = name.toLocaleLowerCase("en-US");
|
|
243
|
+
if (ids.has(id) || names.has(folded)) throw new Error("duplicate remote workspace device identity");
|
|
244
|
+
ids.add(id);
|
|
245
|
+
names.add(folded);
|
|
246
|
+
if (typeof device.tokenHash !== "string") throw new Error("invalid remote workspace token hash");
|
|
247
|
+
parseHash(device.tokenHash);
|
|
248
|
+
const tokenHash = device.tokenHash;
|
|
249
|
+
return {
|
|
250
|
+
id,
|
|
251
|
+
name,
|
|
252
|
+
platform: boundedText(device.platform, "device platform", 80),
|
|
253
|
+
publicKey: validatePublicKey(device.publicKey),
|
|
254
|
+
tokenHash,
|
|
255
|
+
capabilities: parseRemoteWorkspaceCapabilities(device.capabilities),
|
|
256
|
+
roots: validateRoots(device.roots),
|
|
257
|
+
createdAt: validateDate(device.createdAt)!,
|
|
258
|
+
lastSeenAt: validateDate(device.lastSeenAt, true),
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
version: REMOTE_WORKSPACE_HUB_STATE_VERSION,
|
|
263
|
+
identity: validateIdentity(raw.identity),
|
|
264
|
+
devices,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class RemoteWorkspaceHubFileStore implements RemoteWorkspaceHubStateStore {
|
|
269
|
+
constructor(
|
|
270
|
+
private readonly path = join(getConfigDir(), "remote-workspace-hub.json"),
|
|
271
|
+
private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions,
|
|
272
|
+
) {}
|
|
273
|
+
|
|
274
|
+
load(): RemoteWorkspaceHubState | null {
|
|
275
|
+
if (!workspaceSecretFileExists(this.path)) return null;
|
|
276
|
+
this.permissions.prepareDirectory(dirname(this.path));
|
|
277
|
+
this.permissions.hardenFile(this.path);
|
|
278
|
+
const metadata = statSync(this.path);
|
|
279
|
+
if (!metadata.isFile() || metadata.size > MAX_HUB_STATE_BYTES) {
|
|
280
|
+
throw new Error("remote workspace hub state is too large");
|
|
281
|
+
}
|
|
282
|
+
return parseRemoteWorkspaceHubState(JSON.parse(readFileSync(this.path, "utf8")));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
save(state: RemoteWorkspaceHubState): void {
|
|
286
|
+
this.permissions.prepareDirectory(dirname(this.path));
|
|
287
|
+
if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path);
|
|
288
|
+
atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceHubState(state), null, 2)}\n`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export class RemoteWorkspaceHub {
|
|
293
|
+
private state: RemoteWorkspaceHubState;
|
|
294
|
+
private readonly grants = new Map<string, PendingPairingGrant>();
|
|
295
|
+
private readonly pairingSourceFailures = new Map<string, PairingSourceFailureRecord>();
|
|
296
|
+
private readonly connections = new Map<string, RemoteWorkspaceHubAgentConnection>();
|
|
297
|
+
|
|
298
|
+
constructor(
|
|
299
|
+
private readonly store: RemoteWorkspaceHubStateStore,
|
|
300
|
+
private readonly now: () => number = Date.now,
|
|
301
|
+
) {
|
|
302
|
+
const loaded = store.load();
|
|
303
|
+
this.state = loaded ?? {
|
|
304
|
+
version: REMOTE_WORKSPACE_HUB_STATE_VERSION,
|
|
305
|
+
identity: generateRemoteControlIdentityKeyPair(),
|
|
306
|
+
devices: [],
|
|
307
|
+
};
|
|
308
|
+
if (loaded === null) this.store.save(this.state);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
identity(): RemoteControlIdentityKeyPair {
|
|
312
|
+
return { ...this.state.identity };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
createPairingGrant(): RemoteWorkspacePairingGrant {
|
|
316
|
+
this.pruneGrants();
|
|
317
|
+
if (this.grants.size >= MAX_PAIRING_GRANTS) throw new Error("remote workspace pairing capacity reached");
|
|
318
|
+
let code: string;
|
|
319
|
+
let digest: Buffer;
|
|
320
|
+
do {
|
|
321
|
+
code = newPairingCode();
|
|
322
|
+
digest = sha256(normalizeCode(code));
|
|
323
|
+
} while (this.grants.has(encodeHash(digest)));
|
|
324
|
+
const expiresAt = this.now() + PAIRING_LIFETIME_MS;
|
|
325
|
+
this.grants.set(encodeHash(digest), { hash: digest, expiresAt });
|
|
326
|
+
return { code, expiresAt: new Date(expiresAt).toISOString() };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private pairingSourceKey(source: string): string {
|
|
330
|
+
return encodeHash(sha256(`remote-workspace-pairing-source\0${source}`));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private prunePairingSourceFailures(now: number): void {
|
|
334
|
+
// Records never extend their original fixed window, so insertion order is expiry order. Stop
|
|
335
|
+
// at the first live entry instead of making every unauthenticated request scan the full cap.
|
|
336
|
+
for (const [key, record] of this.pairingSourceFailures) {
|
|
337
|
+
if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now) break;
|
|
338
|
+
this.pairingSourceFailures.delete(key);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private pairingSourceRecord(source: string, now: number): [string, PairingSourceFailureRecord | undefined] {
|
|
343
|
+
this.prunePairingSourceFailures(now);
|
|
344
|
+
const key = this.pairingSourceKey(source);
|
|
345
|
+
return [key, this.pairingSourceFailures.get(key)];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private admitPairingSource(source: string, now: number): string {
|
|
349
|
+
const [key, record] = this.pairingSourceRecord(source, now);
|
|
350
|
+
if (record && record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) {
|
|
351
|
+
const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now);
|
|
352
|
+
throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source");
|
|
353
|
+
}
|
|
354
|
+
return key;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
assertPairingSourceAllowed(source = "anonymous"): void {
|
|
358
|
+
this.admitPairingSource(source, this.now());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private recordPairingSourceFailure(key: string, now: number): void {
|
|
362
|
+
let record = this.pairingSourceFailures.get(key);
|
|
363
|
+
if (!record) {
|
|
364
|
+
if (this.pairingSourceFailures.size >= PAIRING_SOURCE_LIMIT) {
|
|
365
|
+
throw new RemoteWorkspacePairingRateLimitError(1, "capacity");
|
|
366
|
+
}
|
|
367
|
+
record = { failures: 0, windowStartedAt: now };
|
|
368
|
+
this.pairingSourceFailures.set(key, record);
|
|
369
|
+
}
|
|
370
|
+
record.failures += 1;
|
|
371
|
+
if (record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) {
|
|
372
|
+
const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now);
|
|
373
|
+
throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
pairDevice(input: unknown, source = "anonymous"): RemoteWorkspacePairDeviceResult {
|
|
378
|
+
this.pruneGrants();
|
|
379
|
+
const nowMs = this.now();
|
|
380
|
+
const sourceKey = this.admitPairingSource(source, nowMs);
|
|
381
|
+
const raw = objectRecord(input);
|
|
382
|
+
const normalizedCode = normalizeCode(typeof raw.code === "string" ? raw.code : "");
|
|
383
|
+
if (normalizedCode.length !== 12 || ![...normalizedCode].every(character => PAIRING_ALPHABET.includes(character))) {
|
|
384
|
+
this.recordPairingSourceFailure(sourceKey, nowMs);
|
|
385
|
+
throw new Error("invalid or expired remote workspace pairing code");
|
|
386
|
+
}
|
|
387
|
+
const digest = sha256(normalizedCode);
|
|
388
|
+
const key = encodeHash(digest);
|
|
389
|
+
const grant = this.grants.get(key);
|
|
390
|
+
if (!grant || grant.expiresAt <= nowMs || !timingSafeEqual(grant.hash, digest)) {
|
|
391
|
+
this.recordPairingSourceFailure(sourceKey, nowMs);
|
|
392
|
+
throw new Error("invalid or expired remote workspace pairing code");
|
|
393
|
+
}
|
|
394
|
+
this.pairingSourceFailures.delete(sourceKey);
|
|
395
|
+
// A valid grant is one-shot even when the submitted device metadata is rejected. Keeping it
|
|
396
|
+
// alive after a conflict would let the same copied secret authorize repeated enrollment tries.
|
|
397
|
+
this.grants.delete(key);
|
|
398
|
+
exactPairingFields(raw);
|
|
399
|
+
if (this.state.devices.length >= REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit reached");
|
|
400
|
+
const name = boundedText(raw.name, "device name", 80);
|
|
401
|
+
const folded = name.toLocaleLowerCase("en-US");
|
|
402
|
+
if (this.state.devices.some(device => device.name.toLocaleLowerCase("en-US") === folded)) {
|
|
403
|
+
throw new Error("remote workspace device name is already in use");
|
|
404
|
+
}
|
|
405
|
+
const now = new Date(nowMs).toISOString();
|
|
406
|
+
const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
407
|
+
const device: RemoteWorkspaceStoredDevice = {
|
|
408
|
+
id: randomUUID(),
|
|
409
|
+
name,
|
|
410
|
+
platform: boundedText(raw.platform, "device platform", 80),
|
|
411
|
+
publicKey: validatePublicKey(raw.publicKey),
|
|
412
|
+
tokenHash: encodeHash(sha256(token)),
|
|
413
|
+
capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities),
|
|
414
|
+
roots: validateRoots(raw.roots),
|
|
415
|
+
createdAt: now,
|
|
416
|
+
lastSeenAt: null,
|
|
417
|
+
};
|
|
418
|
+
this.state = { ...this.state, devices: [...this.state.devices, device] };
|
|
419
|
+
this.store.save(this.state);
|
|
420
|
+
return {
|
|
421
|
+
device: this.publicDevice(device),
|
|
422
|
+
deviceToken: token,
|
|
423
|
+
hubPublicKey: this.state.identity.publicKey,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
authenticateDeviceToken(token: string): RemoteWorkspaceStoredDevice | null {
|
|
428
|
+
if (!token.startsWith(TOKEN_PREFIX) || token.length !== TOKEN_PREFIX.length + 43) return null;
|
|
429
|
+
const digest = sha256(token);
|
|
430
|
+
for (const device of this.state.devices) {
|
|
431
|
+
const stored = parseHash(device.tokenHash);
|
|
432
|
+
if (timingSafeEqual(stored, digest)) {
|
|
433
|
+
return { ...device, capabilities: [...device.capabilities], roots: device.roots.map(root => ({ ...root })) };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
attachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void {
|
|
440
|
+
const index = this.state.devices.findIndex(device => device.id === deviceId);
|
|
441
|
+
if (index < 0) throw new Error("unknown remote workspace device");
|
|
442
|
+
const previous = this.connections.get(deviceId);
|
|
443
|
+
if (previous && previous !== connection) previous.close("remote workspace executor reconnected");
|
|
444
|
+
this.connections.set(deviceId, connection);
|
|
445
|
+
const seen = new Date(this.now()).toISOString();
|
|
446
|
+
this.state = {
|
|
447
|
+
...this.state,
|
|
448
|
+
devices: this.state.devices.map((device, deviceIndex) => (
|
|
449
|
+
deviceIndex === index ? { ...device, lastSeenAt: seen } : device
|
|
450
|
+
)),
|
|
451
|
+
};
|
|
452
|
+
this.store.save(this.state);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
updateDeviceCapabilities(deviceId: string, capabilities: readonly RemoteWorkspaceCapability[]): void {
|
|
456
|
+
const device = this.state.devices.find(candidate => candidate.id === deviceId);
|
|
457
|
+
if (!device) throw new Error("unknown remote workspace device");
|
|
458
|
+
const normalized = parseRemoteWorkspaceCapabilities(capabilities);
|
|
459
|
+
if (normalized.some(capability => !device.capabilities.includes(capability))) {
|
|
460
|
+
throw new Error("remote workspace presence exceeds enrollment grant");
|
|
461
|
+
}
|
|
462
|
+
// Connection availability is transient; the persisted enrollment grant is unchanged.
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
detachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void {
|
|
466
|
+
if (this.connections.get(deviceId) !== connection) return;
|
|
467
|
+
this.connections.delete(deviceId);
|
|
468
|
+
connection.close();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
connection(deviceId: string): RemoteWorkspaceHubAgentConnection | null {
|
|
472
|
+
const connection = this.connections.get(deviceId);
|
|
473
|
+
return connection?.isOnline() ? connection : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
listDevices(): RemoteWorkspacePublicDevice[] {
|
|
477
|
+
return this.state.devices.map(device => this.publicDevice(device));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
revokeDevice(deviceId: string): boolean {
|
|
481
|
+
const before = this.state.devices.length;
|
|
482
|
+
this.state = { ...this.state, devices: this.state.devices.filter(device => device.id !== deviceId) };
|
|
483
|
+
if (this.state.devices.length === before) return false;
|
|
484
|
+
const connection = this.connections.get(deviceId);
|
|
485
|
+
this.connections.delete(deviceId);
|
|
486
|
+
connection?.close("remote workspace device was revoked");
|
|
487
|
+
this.store.save(this.state);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
closeAllConnections(reason = "remote workspace hub stopped"): void {
|
|
492
|
+
const connections = [...this.connections.values()];
|
|
493
|
+
this.connections.clear();
|
|
494
|
+
for (const connection of connections) connection.close(reason);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private publicDevice(device: RemoteWorkspaceStoredDevice): RemoteWorkspacePublicDevice {
|
|
498
|
+
return {
|
|
499
|
+
id: device.id,
|
|
500
|
+
name: device.name,
|
|
501
|
+
platform: device.platform,
|
|
502
|
+
capabilities: device.capabilities.filter(capability => {
|
|
503
|
+
const connection = this.connections.get(device.id);
|
|
504
|
+
return !connection || connection.capabilities().includes(capability);
|
|
505
|
+
}),
|
|
506
|
+
roots: device.roots.map(root => ({ ...root })),
|
|
507
|
+
online: this.connections.get(device.id)?.isOnline() ?? false,
|
|
508
|
+
createdAt: device.createdAt,
|
|
509
|
+
lastSeenAt: device.lastSeenAt,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private pruneGrants(): void {
|
|
514
|
+
const now = this.now();
|
|
515
|
+
for (const [key, grant] of this.grants) {
|
|
516
|
+
if (grant.expiresAt <= now) this.grants.delete(key);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|