@ccpocket/bridge 1.71.0 → 1.72.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/websocket.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile, execFileSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { lstat, readFile, readlink, stat, unlink } from "node:fs/promises";
@@ -96,6 +96,7 @@ const OPT_IN_SERVER_MESSAGES = new Set([
96
96
  "goal_state",
97
97
  "guardian_approval",
98
98
  "prompt_history_status",
99
+ "push_registration_result",
99
100
  ]);
100
101
  function isRecord(value) {
101
102
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -485,6 +486,9 @@ export class BridgeWebSocketServer {
485
486
  /** FCM token → push notification locale */
486
487
  tokenLocales = new Map();
487
488
  tokenPrivacyMode = new Map();
489
+ pushTokenGeneration = new Map();
490
+ pushTokenOperations = new Map();
491
+ nextPushTokenGeneration = 0;
488
492
  failSetPermissionMode = envFlagEnabled("BRIDGE_FAIL_SET_PERMISSION_MODE");
489
493
  failSetSandboxMode = envFlagEnabled("BRIDGE_FAIL_SET_SANDBOX_MODE");
490
494
  fileListMaxEntries;
@@ -579,6 +583,14 @@ export class BridgeWebSocketServer {
579
583
  errorCode: "path_not_allowed",
580
584
  };
581
585
  }
586
+ sendToolActionError(ws, message, error) {
587
+ this.send(ws, {
588
+ type: "error",
589
+ message: error,
590
+ sessionId: message.sessionId,
591
+ toolUseId: message.toolUseId ?? message.id,
592
+ });
593
+ }
582
594
  normalizeAdditionalWritableRoots(roots, projectPath) {
583
595
  if (!roots || roots.length === 0)
584
596
  return {};
@@ -2243,33 +2255,64 @@ export class BridgeWebSocketServer {
2243
2255
  case "push_register": {
2244
2256
  const locale = normalizePushLocale(msg.locale);
2245
2257
  const privacyMode = msg.privacyMode === true;
2258
+ const supportsRegistrationResult = this.clientSupportedServerMessages
2259
+ .get(ws)
2260
+ ?.has("push_registration_result") ?? false;
2246
2261
  console.log(`[ws] push_register received (platform: ${msg.platform}, locale: ${locale}, privacy: ${privacyMode}, configured: ${this.pushRelay.isConfigured})`);
2262
+ const generation = this.beginPushTokenOperation(msg.token);
2247
2263
  if (!this.pushRelay.isConfigured) {
2248
- this.send(ws, {
2249
- type: "error",
2250
- message: "Push relay is not configured on bridge",
2251
- });
2264
+ const error = "Push relay is not configured on bridge";
2265
+ this.send(ws, supportsRegistrationResult
2266
+ ? {
2267
+ type: "push_registration_result",
2268
+ token: msg.token,
2269
+ requestId: msg.requestId ?? "",
2270
+ success: false,
2271
+ error,
2272
+ }
2273
+ : { type: "error", message: error });
2252
2274
  return;
2253
2275
  }
2254
- this.tokenLocales.set(msg.token, locale);
2255
- this.tokenPrivacyMode.set(msg.token, privacyMode);
2256
- this.pushRelay
2257
- .registerToken(msg.token, msg.platform, locale)
2276
+ const operation = this.enqueuePushTokenOperation(msg.token, () => this.pushRelay.registerToken(msg.token, msg.platform, locale));
2277
+ operation
2258
2278
  .then(() => {
2279
+ if (!this.isCurrentPushTokenOperation(msg.token, generation))
2280
+ return;
2259
2281
  console.log("[ws] push_register: token registered successfully");
2282
+ if (supportsRegistrationResult) {
2283
+ this.send(ws, {
2284
+ type: "push_registration_result",
2285
+ token: msg.token,
2286
+ requestId: msg.requestId ?? "",
2287
+ success: true,
2288
+ });
2289
+ }
2290
+ // Enable delivery only after the acknowledgement has been queued
2291
+ // on the WebSocket, keeping the app on local fallback until then.
2292
+ this.tokenLocales.set(msg.token, locale);
2293
+ this.tokenPrivacyMode.set(msg.token, privacyMode);
2260
2294
  })
2261
2295
  .catch((err) => {
2296
+ if (!this.isCurrentPushTokenOperation(msg.token, generation))
2297
+ return;
2262
2298
  const detail = err instanceof Error ? err.message : String(err);
2263
2299
  console.error(`[ws] push_register failed: ${detail}`);
2264
- this.send(ws, {
2265
- type: "error",
2266
- message: `Failed to register push token: ${detail}`,
2267
- });
2300
+ const error = `Failed to register push token: ${detail}`;
2301
+ this.send(ws, supportsRegistrationResult
2302
+ ? {
2303
+ type: "push_registration_result",
2304
+ token: msg.token,
2305
+ requestId: msg.requestId ?? "",
2306
+ success: false,
2307
+ error,
2308
+ }
2309
+ : { type: "error", message: error });
2268
2310
  });
2269
2311
  break;
2270
2312
  }
2271
2313
  case "push_unregister": {
2272
2314
  console.log("[ws] push_unregister received");
2315
+ const generation = this.beginPushTokenOperation(msg.token);
2273
2316
  if (!this.pushRelay.isConfigured) {
2274
2317
  this.send(ws, {
2275
2318
  type: "error",
@@ -2277,14 +2320,15 @@ export class BridgeWebSocketServer {
2277
2320
  });
2278
2321
  return;
2279
2322
  }
2280
- this.tokenLocales.delete(msg.token);
2281
- this.tokenPrivacyMode.delete(msg.token);
2282
- this.pushRelay
2283
- .unregisterToken(msg.token)
2323
+ this.enqueuePushTokenOperation(msg.token, () => this.pushRelay.unregisterToken(msg.token))
2284
2324
  .then(() => {
2325
+ if (!this.isCurrentPushTokenOperation(msg.token, generation))
2326
+ return;
2285
2327
  console.log("[ws] push_unregister: token unregistered successfully");
2286
2328
  })
2287
2329
  .catch((err) => {
2330
+ if (!this.isCurrentPushTokenOperation(msg.token, generation))
2331
+ return;
2288
2332
  const detail = err instanceof Error ? err.message : String(err);
2289
2333
  console.error(`[ws] push_unregister failed: ${detail}`);
2290
2334
  this.send(ws, {
@@ -2964,11 +3008,14 @@ export class BridgeWebSocketServer {
2964
3008
  case "approve": {
2965
3009
  const session = this.resolveSession(msg.sessionId);
2966
3010
  if (!session) {
2967
- this.send(ws, { type: "error", message: "No active session." });
3011
+ this.sendToolActionError(ws, msg, "No active session.");
2968
3012
  return;
2969
3013
  }
2970
3014
  if (session.provider === "codex") {
2971
- session.process.approve(msg.id);
3015
+ const handled = session.process.approve(msg.id);
3016
+ if (handled === false) {
3017
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3018
+ }
2972
3019
  break;
2973
3020
  }
2974
3021
  const sdkProc = session.process;
@@ -3016,70 +3063,85 @@ export class BridgeWebSocketServer {
3016
3063
  this.broadcastSessionList();
3017
3064
  }
3018
3065
  else {
3019
- sdkProc.approve(msg.id);
3066
+ const handled = sdkProc.approve(msg.id);
3067
+ if (handled === false) {
3068
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3069
+ }
3020
3070
  }
3021
3071
  break;
3022
3072
  }
3023
3073
  case "approve_always": {
3024
3074
  const session = this.resolveSession(msg.sessionId);
3025
3075
  if (!session) {
3026
- this.send(ws, { type: "error", message: "No active session." });
3076
+ this.sendToolActionError(ws, msg, "No active session.");
3027
3077
  return;
3028
3078
  }
3029
3079
  if (session.provider === "codex") {
3030
- session.process.approveAlways(msg.id);
3080
+ const handled = session.process.approveAlways(msg.id);
3081
+ if (handled === false) {
3082
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3083
+ }
3031
3084
  break;
3032
3085
  }
3033
- session.process.approveAlways(msg.id);
3086
+ const handled = session.process.approveAlways(msg.id);
3087
+ if (handled === false) {
3088
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3089
+ }
3034
3090
  break;
3035
3091
  }
3036
3092
  case "reject": {
3037
3093
  const session = this.resolveSession(msg.sessionId);
3038
3094
  if (!session) {
3039
- this.send(ws, { type: "error", message: "No active session." });
3095
+ this.sendToolActionError(ws, msg, "No active session.");
3040
3096
  return;
3041
3097
  }
3042
3098
  if (session.provider === "codex") {
3043
- session.process.reject(msg.id, msg.message);
3099
+ const handled = session.process.reject(msg.id, msg.message);
3100
+ if (handled === false) {
3101
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3102
+ }
3044
3103
  break;
3045
3104
  }
3046
- session.process.reject(msg.id, msg.message);
3105
+ const handled = session.process.reject(msg.id, msg.message);
3106
+ if (handled === false) {
3107
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3108
+ }
3047
3109
  break;
3048
3110
  }
3049
3111
  case "answer": {
3050
3112
  const session = this.resolveSession(msg.sessionId);
3051
3113
  if (!session) {
3052
- this.send(ws, { type: "error", message: "No active session." });
3114
+ this.sendToolActionError(ws, msg, "No active session.");
3053
3115
  return;
3054
3116
  }
3055
3117
  if (session.provider === "codex") {
3056
- session.process.answer(msg.toolUseId, msg.result);
3118
+ const handled = session.process.answer(msg.toolUseId, msg.result);
3119
+ if (handled === false) {
3120
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3121
+ }
3057
3122
  break;
3058
3123
  }
3059
- session.process.answer(msg.toolUseId, msg.result);
3124
+ const handled = session.process.answer(msg.toolUseId, msg.result);
3125
+ if (handled === false) {
3126
+ this.sendToolActionError(ws, msg, "No matching pending tool action.");
3127
+ }
3060
3128
  break;
3061
3129
  }
3062
3130
  case "install_tool_suggestion": {
3063
3131
  const session = this.resolveSession(msg.sessionId);
3064
3132
  if (!session) {
3065
- this.send(ws, { type: "error", message: "No active session." });
3133
+ this.sendToolActionError(ws, msg, "No active session.");
3066
3134
  return;
3067
3135
  }
3068
3136
  if (session.provider !== "codex") {
3069
- this.send(ws, {
3070
- type: "error",
3071
- message: "Tool suggestions are only supported for Codex sessions.",
3072
- });
3137
+ this.sendToolActionError(ws, msg, "Tool suggestions are only supported for Codex sessions.");
3073
3138
  return;
3074
3139
  }
3075
3140
  try {
3076
3141
  await session.process.installToolSuggestion(msg.toolUseId);
3077
3142
  }
3078
3143
  catch (err) {
3079
- this.send(ws, {
3080
- type: "error",
3081
- message: err instanceof Error ? err.message : String(err),
3082
- });
3144
+ this.sendToolActionError(ws, msg, err instanceof Error ? err.message : String(err));
3083
3145
  }
3084
3146
  break;
3085
3147
  }
@@ -3882,7 +3944,7 @@ export class BridgeWebSocketServer {
3882
3944
  }
3883
3945
  case "list_directory": {
3884
3946
  try {
3885
- const listing = await listAllowedDirectories(msg.path, this.allowedDirs, this.platform);
3947
+ const listing = await listAllowedDirectories(msg.path, this.allowedDirs, this.platform, msg.includeHidden ?? false);
3886
3948
  this.send(ws, {
3887
3949
  type: "directory_listing",
3888
3950
  path: listing.path,
@@ -6015,10 +6077,40 @@ export class BridgeWebSocketServer {
6015
6077
  const parts = session.projectPath.replace(/\/+$/, "").split("/");
6016
6078
  return parts[parts.length - 1] || "";
6017
6079
  }
6018
- /** Get unique locales from registered tokens. Falls back to ["en"] if none registered. */
6080
+ beginPushTokenOperation(token) {
6081
+ const generation = ++this.nextPushTokenGeneration;
6082
+ this.pushTokenGeneration.set(token, generation);
6083
+ // Local delivery is disabled synchronously while the relay operation is
6084
+ // pending. The app stays on local fallback until a successful ACK.
6085
+ this.tokenLocales.delete(token);
6086
+ this.tokenPrivacyMode.delete(token);
6087
+ return generation;
6088
+ }
6089
+ enqueuePushTokenOperation(token, operation) {
6090
+ const previous = this.pushTokenOperations.get(token);
6091
+ const pending = (previous?.catch(() => { }) ?? Promise.resolve()).then(operation);
6092
+ this.pushTokenOperations.set(token, pending);
6093
+ const cleanup = () => {
6094
+ if (this.pushTokenOperations.get(token) === pending) {
6095
+ this.pushTokenOperations.delete(token);
6096
+ }
6097
+ };
6098
+ void pending.then(cleanup, cleanup);
6099
+ return pending;
6100
+ }
6101
+ isCurrentPushTokenOperation(token, generation) {
6102
+ return this.pushTokenGeneration.get(token) === generation;
6103
+ }
6104
+ /** Get unique locales from tokens acknowledged by the push relay. */
6019
6105
  getRegisteredLocales() {
6020
6106
  const locales = new Set(this.tokenLocales.values());
6021
- return locales.size > 0 ? [...locales] : ["en"];
6107
+ return [...locales];
6108
+ }
6109
+ /** Hashes of relay tokens that are currently safe for remote delivery. */
6110
+ getActivePushTokenHashes(locale) {
6111
+ return [...this.tokenLocales]
6112
+ .filter(([, tokenLocale]) => tokenLocale === locale)
6113
+ .map(([token]) => createHash("sha256").update(token).digest("hex"));
6022
6114
  }
6023
6115
  /** Whether any registered token has privacy mode enabled (conservative: privacy wins). */
6024
6116
  isPrivacyMode() {
@@ -6040,6 +6132,8 @@ export class BridgeWebSocketServer {
6040
6132
  maybeSendPushNotification(sessionId, msg) {
6041
6133
  if (!this.pushRelay.isConfigured)
6042
6134
  return;
6135
+ if (this.tokenLocales.size === 0)
6136
+ return;
6043
6137
  const privacy = this.isPrivacyMode();
6044
6138
  const label = privacy ? "" : this.sessionLabel(sessionId);
6045
6139
  if (msg.type === "permission_request") {
@@ -6104,6 +6198,7 @@ export class BridgeWebSocketServer {
6104
6198
  title,
6105
6199
  body,
6106
6200
  locale,
6201
+ tokenHashes: this.getActivePushTokenHashes(locale),
6107
6202
  data,
6108
6203
  })
6109
6204
  .catch((err) => {
@@ -6177,6 +6272,7 @@ export class BridgeWebSocketServer {
6177
6272
  title,
6178
6273
  body,
6179
6274
  locale,
6275
+ tokenHashes: this.getActivePushTokenHashes(locale),
6180
6276
  data,
6181
6277
  })
6182
6278
  .catch((err) => {