@awak-app/simy-cli 0.5.62 → 0.5.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,13 @@ and shows active memberships of the connected SIMY account. Switching opens brow
16
16
  and waits for active tasks to finish. Use the same account in the browser; cancel
17
17
  an unfinished switch from the sidebar.
18
18
 
19
+ Browser authorization links expire after **20 minutes**. The app shows **Connection
20
+ timed out** and **Reconnect** when an active request expires. Reconnect starts a
21
+ new link; old or canceled links cannot complete authorization. Installed clients
22
+ with the loopback redirect transport finish on a self-contained local browser page
23
+ so Safari does not need an HTTPS-to-HTTP cross-origin fetch. The session itself
24
+ still lasts 48 hours; the 20-minute limit is the browser connection window.
25
+
19
26
  **Local SQM**, above the connection status, allows this Mac's Codex / Claude Code
20
27
  to claim local SQM reviews. It defaults to off and is saved separately for each
21
28
  Web environment, account, and organization. Turning it off stops new claims;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.62",
3
+ "version": "0.5.64",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { BROWSER_HANDOFF_TRANSPORT, browserHandoffPage, decryptBrowserHandoff } from "./browser-handoff.js";
2
+ import { createAuthorizationWindow } from "./browser-authorization.js";
1
3
  import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
2
4
  import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
3
5
  import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
@@ -199,6 +201,23 @@ export async function startAgent({
199
201
  let organizationState = "unknown";
200
202
  let organizationRefresh = null;
201
203
  let pendingOrganization = null;
204
+ let desktopLoginPending = false;
205
+ let authorizationCompleting = false;
206
+ let expiredOrganizationId = null;
207
+ let expiredInteractiveAuthorization = false;
208
+ const authorizationWindow = createAuthorizationWindow({
209
+ ...dependencies.authorizationClock,
210
+ onExpire() {
211
+ expiredInteractiveAuthorization = Boolean(pendingOrganization || desktopLoginPending);
212
+ expiredOrganizationId = pendingOrganization?.id ?? null;
213
+ authNonce = randomBytes(16).toString("base64url");
214
+ pendingOrganization = null;
215
+ desktopLoginPending = false;
216
+ loginUrl = null;
217
+ refreshAvailableCapabilities();
218
+ notifyDesktopStatusChange();
219
+ },
220
+ });
202
221
  await cleanupExpiredRuns();
203
222
  for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
204
223
  localTaskRegistry.create(task);
@@ -469,7 +488,19 @@ export async function startAgent({
469
488
  })();
470
489
  return organizationRefresh;
471
490
  };
472
- const requestDesktopLogin = (organizationId = null) => {
491
+ const requestDesktopLogin = (organizationId = null, { passive = false } = {}) => {
492
+ const authorization = authorizationWindow.snapshot();
493
+ if (authorization.state === "timed_out") {
494
+ if (passive) return null;
495
+ organizationId ??= expiredOrganizationId;
496
+ }
497
+ // Reopening the verification page must keep the original nonce and scope.
498
+ if (!organizationId && pendingOrganization) return loginUrl?.toString() ?? null;
499
+ // The first user action gets a full window, independent of app startup.
500
+ if (!passive && !organizationId && !pendingOrganization && !desktopLoginPending && loginUrl) {
501
+ authNonce = randomBytes(16).toString("base64url");
502
+ loginUrl = null;
503
+ }
473
504
  let organizationSlug = null;
474
505
  if (organizationId) {
475
506
  if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
@@ -486,13 +517,19 @@ export async function startAgent({
486
517
  notifyDesktopStatusChange();
487
518
  } else if (isSessionValid(session, Date.now(), apiOrigin)) {
488
519
  if (!enforceDesktopAccess || desktopAccess.snapshot().state !== 'signed_out') return null;
489
- authNonce = randomBytes(16).toString("base64url");
490
- loginUrl = null;
520
+ if (!passive || !loginUrl) {
521
+ authNonce = randomBytes(16).toString("base64url");
522
+ loginUrl = null;
523
+ }
491
524
  }
492
525
  if (!loginUrl) {
526
+ authorizationWindow.start();
527
+ expiredOrganizationId = null;
528
+ expiredInteractiveAuthorization = false;
493
529
  loginUrl = new URL("/local-cli/connect", apiOrigin);
494
530
  loginUrl.searchParams.set("port", String(port));
495
531
  loginUrl.searchParams.set("nonce", authNonce);
532
+ loginUrl.searchParams.set("auth_transport", BROWSER_HANDOFF_TRANSPORT);
496
533
  loginUrl.searchParams.set("cli_version", CLI_VERSION);
497
534
  loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
498
535
  if (installMode) loginUrl.searchParams.set("install_mode", installMode);
@@ -501,6 +538,7 @@ export async function startAgent({
501
538
  loginUrl.searchParams.set('org_slug', organizationSlug);
502
539
  }
503
540
  }
541
+ if (!passive) { desktopLoginPending = true; notifyDesktopStatusChange(); }
504
542
  return loginUrl.toString();
505
543
  };
506
544
 
@@ -691,6 +729,8 @@ export async function startAgent({
691
729
  if (session?.device_id && session.token) reclaimableSession = session;
692
730
  session = null;
693
731
  loginUrl = null;
732
+ pendingOrganization = null;
733
+ desktopLoginPending = false;
694
734
  refreshAvailableCapabilities();
695
735
  requestDesktopLogin();
696
736
  }
@@ -878,7 +918,11 @@ export async function startAgent({
878
918
 
879
919
  const server = createServer(async (req, res) => {
880
920
  try {
881
- if (!isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
921
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
922
+ const localHandoff = url.pathname === "/v1/auth/browser-complete";
923
+ const localOrigin = `http://127.0.0.1:${port}`;
924
+ if (localHandoff ? (req.headers.origin !== localOrigin || req.headers.host !== `127.0.0.1:${port}`)
925
+ : !isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
882
926
  json(res, 403, { error: "request origin does not match the configured SIMY Web host" });
883
927
  return;
884
928
  }
@@ -888,9 +932,14 @@ export async function startAgent({
888
932
  return;
889
933
  }
890
934
 
891
- const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
935
+ if (req.method === "GET" && url.pathname === "/v1/auth/browser") {
936
+ if (req.headers.host !== `127.0.0.1:${port}`) { json(res, 403, { error: "Invalid local host." }); return; }
937
+ const page = browserHandoffPage();
938
+ res.writeHead(200, page.headers).end(page.html);
939
+ return;
940
+ }
892
941
  const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
893
- const desktopRecoveryRoute = ['/v1/health', '/v1/capabilities', '/v1/auth/complete'].includes(url.pathname) ||
942
+ const desktopRecoveryRoute = ['/v1/health', '/v1/capabilities', '/v1/auth/complete', '/v1/auth/browser-complete'].includes(url.pathname) ||
894
943
  req.method === 'POST' && (/^\/v1\/local-tasks\/[^/]+\/control$/.test(url.pathname) || /^\/v1\/agentic-loop\/[^/]+\/control$/.test(agenticLoopPath));
895
944
  let requestAccessScope;
896
945
  if (enforceDesktopAccess && !desktopRecoveryRoute) {
@@ -1112,105 +1161,127 @@ export async function startAgent({
1112
1161
  json(res, 200, environment);
1113
1162
  return;
1114
1163
  }
1115
- if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
1116
- const body = await readJson(req);
1117
- if (body.nonce !== authNonce) {
1118
- json(res, 401, { error: "invalid nonce" });
1119
- return;
1120
- }
1121
- if (body.api_origin !== apiOrigin) {
1122
- json(res, 403, { error: "authorization origin does not match the configured host" });
1123
- return;
1124
- }
1125
- let apiBaseUrl;
1164
+ if (req.method === "POST" && ["/v1/auth/complete", "/v1/auth/browser-complete"].includes(url.pathname)) {
1165
+ if (authorizationCompleting) { json(res, 409, { error: "Authorization is already completing." }); return; }
1166
+ authorizationCompleting = true;
1126
1167
  try {
1127
- apiBaseUrl = resolveWebApiBaseUrl(apiOrigin, body.api_base_url);
1128
- } catch (error) {
1129
- json(res, 503, {
1130
- error: error instanceof Error ? error.message : "CLI API endpoint is unavailable",
1131
- });
1132
- return;
1133
- }
1134
- const nextOrganization = body.organization_id ?? body.org_id;
1135
- const nextAccount = body.account_id ?? body.auth_user_id;
1136
- const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
1137
- if ((changingScope && organizationBusy()) || (pendingOrganization &&
1138
- (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
1139
- json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
1140
- return;
1141
- }
1142
- const previousSession = session ?? reclaimableSession;
1143
- reclaimableSession = null;
1144
- session = {
1145
- token: String(body.token || ""),
1146
- device_id: typeof body.device_id === "string" ? body.device_id : null,
1147
- api_origin: apiOrigin,
1148
- api_base_url: apiBaseUrl,
1149
- account_id: typeof body.account_id === "string"
1150
- ? body.account_id
1151
- : typeof body.auth_user_id === "string"
1152
- ? body.auth_user_id
1153
- : null,
1154
- organization_id: typeof body.organization_id === "string"
1155
- ? body.organization_id
1156
- : typeof body.org_id === "string"
1157
- ? body.org_id
1158
- : null,
1159
- expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
1160
- };
1161
- // A fresh sign-in issues a new device. Keep running as the device used
1162
- // before (its desktop WorkFlow evidence stays resumable) when this agent
1163
- // still holds that device's token. Otherwise continue as the new device.
1164
- if (
1165
- previousSession?.device_id &&
1166
- previousSession.token &&
1167
- session.device_id &&
1168
- previousSession.device_id !== session.device_id &&
1169
- previousSession.api_origin &&
1170
- (previousSession.account_id ?? null) === (session.account_id ?? null) &&
1171
- (previousSession.organization_id ?? null) === (session.organization_id ?? null) &&
1172
- previousApiBaseUrl(previousSession) === session.api_base_url
1173
- ) {
1168
+ const requestNonce = authNonce;
1169
+ let body;
1174
1170
  try {
1175
- const reclaimed = await reclaimDevice({
1176
- apiOrigin,
1177
- apiBaseUrl: session.api_base_url,
1178
- token: session.token,
1179
- previousDeviceId: previousSession.device_id,
1180
- previousToken: previousSession.token,
1181
- });
1182
- if (reclaimed?.device_id === previousSession.device_id) {
1183
- session.device_id = reclaimed.device_id;
1184
- if (typeof reclaimed.expires_at === "string") session.expires_at = reclaimed.expires_at;
1185
- }
1171
+ const input = await readBoundedJson(req, 32768);
1172
+ body = localHandoff ? decryptBrowserHandoff(input.handoff, { nonce: requestNonce, apiOrigin, port }) : input;
1173
+ } catch { json(res, 400, { error: "Invalid browser authorization. Reconnect from SIMY." }); return; }
1174
+ if (authorizationWindow.snapshot().state === "timed_out") {
1175
+ json(res, 410, { error: "Browser connection timed out after 20 minutes. Reconnect from SIMY.", code: "authorization_timed_out" });
1176
+ return;
1177
+ }
1178
+ if (body.nonce !== authNonce) {
1179
+ json(res, 401, { error: "invalid nonce" });
1180
+ return;
1181
+ }
1182
+ if (body.api_origin !== apiOrigin) {
1183
+ json(res, 403, { error: "authorization origin does not match the configured host" });
1184
+ return;
1185
+ }
1186
+ let apiBaseUrl;
1187
+ try {
1188
+ apiBaseUrl = resolveWebApiBaseUrl(apiOrigin, body.api_base_url);
1186
1189
  } catch (error) {
1187
- if (!quiet) {
1188
- console.error(
1189
- `SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
1190
- );
1190
+ json(res, 503, {
1191
+ error: error instanceof Error ? error.message : "CLI API endpoint is unavailable",
1192
+ });
1193
+ return;
1194
+ }
1195
+ const nextOrganization = body.organization_id ?? body.org_id;
1196
+ const nextAccount = body.account_id ?? body.auth_user_id;
1197
+ const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
1198
+ if ((changingScope && organizationBusy()) || (pendingOrganization &&
1199
+ (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
1200
+ json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
1201
+ return;
1202
+ }
1203
+ const previousSession = session ?? reclaimableSession;
1204
+ const authorizedSession = {
1205
+ token: String(body.token || ""),
1206
+ device_id: typeof body.device_id === "string" ? body.device_id : null,
1207
+ api_origin: apiOrigin,
1208
+ api_base_url: apiBaseUrl,
1209
+ account_id: typeof body.account_id === "string"
1210
+ ? body.account_id
1211
+ : typeof body.auth_user_id === "string"
1212
+ ? body.auth_user_id
1213
+ : null,
1214
+ organization_id: typeof body.organization_id === "string"
1215
+ ? body.organization_id
1216
+ : typeof body.org_id === "string"
1217
+ ? body.org_id
1218
+ : null,
1219
+ expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
1220
+ };
1221
+ // A fresh sign-in issues a new device. Keep running as the device used
1222
+ // before (its desktop WorkFlow evidence stays resumable) when this agent
1223
+ // still holds that device's token. Otherwise continue as the new device.
1224
+ if (
1225
+ previousSession?.device_id &&
1226
+ previousSession.token &&
1227
+ authorizedSession.device_id &&
1228
+ previousSession.device_id !== authorizedSession.device_id &&
1229
+ previousSession.api_origin &&
1230
+ (previousSession.account_id ?? null) === (authorizedSession.account_id ?? null) &&
1231
+ (previousSession.organization_id ?? null) === (authorizedSession.organization_id ?? null) &&
1232
+ previousApiBaseUrl(previousSession) === authorizedSession.api_base_url
1233
+ ) {
1234
+ try {
1235
+ const reclaimed = await reclaimDevice({
1236
+ apiOrigin,
1237
+ apiBaseUrl: authorizedSession.api_base_url,
1238
+ token: authorizedSession.token,
1239
+ previousDeviceId: previousSession.device_id,
1240
+ previousToken: previousSession.token,
1241
+ });
1242
+ if (reclaimed?.device_id === previousSession.device_id) {
1243
+ authorizedSession.device_id = reclaimed.device_id;
1244
+ if (typeof reclaimed.expires_at === "string") authorizedSession.expires_at = reclaimed.expires_at;
1245
+ }
1246
+ } catch (error) {
1247
+ if (!quiet) {
1248
+ console.error(
1249
+ `SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
1250
+ );
1251
+ }
1191
1252
  }
1192
1253
  }
1193
- }
1194
- await writeSession(apiOrigin, session, sessionRoot);
1195
- authNonce = randomBytes(16).toString("base64url");
1196
- pendingOrganization = null;
1197
- sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1198
- if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1199
- organizationState = "unknown";
1200
- organizationItems = [];
1201
- void refreshOrganizations();
1202
- refreshAvailableCapabilities();
1203
- startHeartbeatTimer();
1204
- loginUrl = null;
1205
- registry.emit("change", registry.list());
1206
- json(res, 200, {
1207
- ok: true,
1208
- expires_at: session.expires_at,
1209
- cli_version: CLI_VERSION,
1210
- api_contract_version: CLI_API_CONTRACT_VERSION,
1211
- });
1212
- void synchronizeAuthorizedSession();
1213
- return;
1254
+ // A cancellation, timeout or new request during device reclaim must
1255
+ // not let this old callback overwrite the current authorization.
1256
+ if (authorizationWindow.snapshot().state === "timed_out" || requestNonce !== authNonce) {
1257
+ json(res, 410, { error: "Connection expired. Reconnect from SIMY." }); return;
1258
+ }
1259
+ await writeSession(apiOrigin, authorizedSession, sessionRoot);
1260
+ session = authorizedSession;
1261
+ reclaimableSession = null;
1262
+ authorizationWindow.clear();
1263
+ expiredOrganizationId = null;
1264
+ authNonce = randomBytes(16).toString("base64url");
1265
+ pendingOrganization = null;
1266
+ desktopLoginPending = false;
1267
+ sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1268
+ if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1269
+ organizationState = "unknown";
1270
+ organizationItems = [];
1271
+ void refreshOrganizations();
1272
+ refreshAvailableCapabilities();
1273
+ startHeartbeatTimer();
1274
+ loginUrl = null;
1275
+ registry.emit("change", registry.list());
1276
+ json(res, 200, {
1277
+ ok: true,
1278
+ expires_at: session.expires_at,
1279
+ cli_version: CLI_VERSION,
1280
+ api_contract_version: CLI_API_CONTRACT_VERSION,
1281
+ });
1282
+ void synchronizeAuthorizedSession();
1283
+ return;
1284
+ } finally { authorizationCompleting = false; }
1214
1285
  }
1215
1286
  const localTaskArtifactMatch = url.pathname.match(
1216
1287
  /^\/v1\/local-tasks\/([^/]+)\/artifacts\/([^/]+)$/,
@@ -2156,7 +2227,7 @@ export async function startAgent({
2156
2227
  }
2157
2228
 
2158
2229
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
2159
- requestDesktopLogin();
2230
+ requestDesktopLogin(null, { passive: true });
2160
2231
  if (!quiet) {
2161
2232
  console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
2162
2233
  console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
@@ -2194,6 +2265,7 @@ export async function startAgent({
2194
2265
  const shutdown = () => {
2195
2266
  if (shutdownPromise) return shutdownPromise;
2196
2267
  shuttingDown = true;
2268
+ authorizationWindow.clear();
2197
2269
  shutdownPromise = (async () => {
2198
2270
  if (heartbeatTimer) clearInterval(heartbeatTimer);
2199
2271
  if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
@@ -2225,6 +2297,7 @@ export async function startAgent({
2225
2297
  return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
2226
2298
  };
2227
2299
  const getDesktopStatus = async () => {
2300
+ const authorization = authorizationWindow.snapshot();
2228
2301
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
2229
2302
  if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
2230
2303
  if (enforceDesktopAccess) void desktopAccess.refresh().catch(() => {});
@@ -2242,6 +2315,9 @@ export async function startAgent({
2242
2315
  version: CLI_VERSION,
2243
2316
  webOrigin: apiOrigin,
2244
2317
  auth,
2318
+ authorization: { ...authorization,
2319
+ state: authorization.state === "timed_out" && !expiredInteractiveAuthorization ? "idle" : authorization.state,
2320
+ pending: Boolean(pendingOrganization || desktopLoginPending) },
2245
2321
  ...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
2246
2322
  connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
2247
2323
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
@@ -2256,7 +2332,8 @@ export async function startAgent({
2256
2332
  codex,
2257
2333
  claude,
2258
2334
  providers: { codex, claude },
2259
- loginUrl: auth.state === "signed_in" ? null : requestDesktopLogin(),
2335
+ // URLs stay inside the host; native UI reopens the current request by action.
2336
+ loginUrl: auth.state === "signed_in" ? null : requestDesktopLogin(null, { passive: true }),
2260
2337
  };
2261
2338
  };
2262
2339
  return {
@@ -2270,9 +2347,14 @@ export async function startAgent({
2270
2347
  shutdown,
2271
2348
  webOrigin: apiOrigin,
2272
2349
  get loginUrl() {
2273
- return requestDesktopLogin();
2350
+ return requestDesktopLogin(null, { passive: true });
2274
2351
  },
2275
2352
  requestDesktopLogin,
2353
+ getPendingAuthorizationUrl: () => {
2354
+ authorizationWindow.snapshot();
2355
+ return pendingOrganization || desktopLoginPending ? loginUrl?.toString() ?? null : null;
2356
+ },
2357
+ retryAuthorization: () => authorizationWindow.snapshot().state === "timed_out" ? requestDesktopLogin() : null,
2276
2358
  async refreshDesktopAccess() {
2277
2359
  const previous = desktopAccess.snapshot().state;
2278
2360
  const result = await desktopAccess.refresh(true);
@@ -2284,7 +2366,7 @@ export async function startAgent({
2284
2366
  getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
2285
2367
  requireDesktopAccess: authorizeDesktopWork,
2286
2368
  refreshOrganizations,
2287
- cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
2369
+ cancelOrganizationSelection() { authorizationWindow.clear(); expiredOrganizationId = null; expiredInteractiveAuthorization = false; authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
2288
2370
  async setSqmEnabled(enabled) {
2289
2371
  if (enabled) await authorizeDesktopWork();
2290
2372
  if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
@@ -0,0 +1,32 @@
1
+ // Browser sign-in and SQM command sign-in share one authorization window.
2
+ export const BROWSER_AUTHORIZATION_TIMEOUT_MS = 20 * 60 * 1000;
3
+
4
+ export function createAuthorizationWindow({ now = Date.now, schedule = setTimeout,
5
+ cancel = clearTimeout, onExpire = () => {} } = {}) {
6
+ let deadline = null, timer = null, state = "idle";
7
+ const expire = () => {
8
+ if (deadline === null || now() < deadline) return;
9
+ if (timer !== null) cancel(timer);
10
+ deadline = timer = null;
11
+ state = "timed_out";
12
+ onExpire();
13
+ };
14
+ return {
15
+ start() {
16
+ if (timer !== null) cancel(timer);
17
+ state = "pending";
18
+ deadline = now() + BROWSER_AUTHORIZATION_TIMEOUT_MS;
19
+ timer = schedule(expire, BROWSER_AUTHORIZATION_TIMEOUT_MS);
20
+ timer?.unref?.();
21
+ },
22
+ clear() {
23
+ if (timer !== null) cancel(timer);
24
+ deadline = timer = null;
25
+ state = "idle";
26
+ },
27
+ snapshot() {
28
+ expire(); // Also enforce the deadline after suspend/resume.
29
+ return { state, expiresAt: deadline === null ? null : new Date(deadline).toISOString() };
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,68 @@
1
+ import { createDecipheriv, createHash } from "node:crypto";
2
+
3
+ export const BROWSER_HANDOFF_TRANSPORT = "loopback_redirect_v1";
4
+
5
+ // Versioned wire protocol also implemented by simy-web's browser WebCrypto.
6
+ // Bind the encrypted fragment to this request, Web host and listening port.
7
+ export function decryptBrowserHandoff(handoff, { nonce, apiOrigin, port }) {
8
+ if (typeof handoff !== "string" || handoff.length > 16384 ||
9
+ !/^v1\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]+$/.test(handoff)) throw new Error("Invalid browser handoff.");
10
+ const [, iv, encrypted] = handoff.split(".");
11
+ const ciphertext = Buffer.from(encrypted, "base64url");
12
+ if (ciphertext.length < 17) throw new Error("Invalid browser handoff.");
13
+ const context = Buffer.from(`simy-browser-handoff-v1\0${apiOrigin}\0${port}`);
14
+ const key = createHash("sha256").update(context).update("\0").update(nonce).digest();
15
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"));
16
+ decipher.setAAD(context);
17
+ decipher.setAuthTag(ciphertext.subarray(-16));
18
+ const body = JSON.parse(Buffer.concat([decipher.update(ciphertext.subarray(0, -16)), decipher.final()]).toString("utf8"));
19
+ if (!body || typeof body !== "object" || body.nonce !== nonce) throw new Error("Invalid browser handoff.");
20
+ return body;
21
+ }
22
+
23
+ const script = `
24
+ const ja = navigator.language.startsWith('ja');
25
+ const copy = ja ? {
26
+ title: 'SIMY に接続しています', waiting: 'このページで接続を完了します。SIMY は開いたままにしてください。',
27
+ connected: '接続しました', done: 'SIMY に戻ってください。このタブは閉じても大丈夫です。',
28
+ failed: '接続できませんでした', retry: 'SIMY に戻り、もう一度接続してください。接続リンクの有効期限は20分です。'
29
+ } : {
30
+ title: 'Connecting to SIMY', waiting: 'Keep SIMY open while this page completes the connection.',
31
+ connected: 'Connected', done: 'Return to SIMY. You can close this tab.',
32
+ failed: 'Could not connect', retry: 'Return to SIMY and reconnect. Connection links expire after 20 minutes.'
33
+ };
34
+ document.documentElement.lang = ja ? 'ja' : 'en';
35
+ const title = document.getElementById('title'), message = document.getElementById('message');
36
+ title.textContent = copy.title; message.textContent = copy.waiting;
37
+ const handoff = new URLSearchParams(location.hash.slice(1)).get('handoff');
38
+ history.replaceState(null, '', location.pathname);
39
+ (async () => {
40
+ try {
41
+ if (!handoff) throw new Error('Missing handoff');
42
+ const response = await fetch('/v1/auth/browser-complete', {
43
+ method: 'POST', headers: {'Content-Type': 'application/json'},
44
+ body: JSON.stringify({handoff}), signal: AbortSignal.timeout(30000)
45
+ });
46
+ if (!response.ok) throw new Error('Rejected handoff');
47
+ title.textContent = copy.connected; message.textContent = copy.done;
48
+ document.body.dataset.state = 'connected';
49
+ } catch {
50
+ title.textContent = copy.failed; message.textContent = copy.retry;
51
+ document.body.dataset.state = 'failed';
52
+ }
53
+ })();`;
54
+
55
+ // A self-contained local page: credentials never enter the URL query, HTTP
56
+ // request, history or third-party resources. Only the encrypted fragment does.
57
+ const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}body[data-state=failed] h1{color:var(--ink)}`;
58
+ const hash = value => createHash("sha256").update(value).digest("base64");
59
+ export function browserHandoffPage() {
60
+ return {
61
+ headers: {
62
+ "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
63
+ "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
64
+ "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
65
+ },
66
+ html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
67
+ };
68
+ }
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.62";
16
+ export const CLI_VERSION = "0.5.64";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -7,7 +7,7 @@ export function validDesktopAccessPath(path, slug) {
7
7
  return path === expected || path === `${expected}/`;
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
- const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'organization_select', 'organization_cancel', 'organizations_refresh',
10
+ const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
11
  'check_update', 'update', 'quit', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
@@ -39,6 +39,7 @@ const silentLogger = { log() {}, error() {} };
39
39
  const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
40
40
 
41
41
  function desktopActionError(action, error) {
42
+ if (action === 'authorization_open' || error?.code === 'desktop_browser_unavailable') return "Could not open your browser. Open the verification link again, or check your default browser.";
42
43
  if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
43
44
  if (action === "session_deadline_decide") return error?.code === "conflict"
44
45
  ? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
@@ -436,16 +437,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
436
437
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
437
438
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
438
439
  else if (action === "organization_cancel") agent.cancelOrganizationSelection();
439
- else if (action === "login" || action === "organization_select") {
440
+ else if (action === "login" || action === "organization_select" || action === "authorization_open") {
440
441
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
441
442
  if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
442
443
  throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
443
444
  }
444
- const url = await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
445
+ const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
446
+ : await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
447
+ if (action === 'authorization_open' && !url) throw new Error('No pending authorization.');
445
448
  if (url) {
446
449
  if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
447
- const result = await (dependencies.openLogin || openAuthorizationUrl)(url, { logger: silentLogger });
448
- if (!result.opened) { agent.cancelOrganizationSelection?.(); throw new Error("Could not open the login page."); }
450
+ // Keep the pending request after an OS/browser failure so the user can
451
+ // use the visible fallback without silently canceling authorization.
452
+ try {
453
+ const result = await (dependencies.openLogin || openAuthorizationUrl)(url, { logger: silentLogger });
454
+ if (!result.opened) throw new Error('Browser unavailable.');
455
+ } catch { throw Object.assign(new Error('Browser unavailable.'), { code: 'desktop_browser_unavailable' }); }
449
456
  }
450
457
  } else if (action === "check_update") await updater.checkNow();
451
458
  else if (action === "update") {
@@ -560,7 +567,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
560
567
  emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
561
568
  }
562
569
  };
563
- if (["login", "organization_select", "organization_cancel"].includes(command.action)) {
570
+ if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
564
571
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
565
572
  } else {
566
573
  queue = queue.then(async () => {
@@ -3,13 +3,13 @@ import { openAuthorizationUrl } from "../browser.js";
3
3
  import { isSessionValid, readSession } from "../session-store.js";
4
4
  import { createAgentShutdown } from "../shutdown.js";
5
5
 
6
- const DEFAULT_AUTH_TIMEOUT_MS = 2 * 60 * 1000;
6
+ import { BROWSER_AUTHORIZATION_TIMEOUT_MS } from "../browser-authorization.js";
7
7
 
8
8
  export async function ensureSqmSession(
9
9
  webOrigin,
10
10
  {
11
11
  noOpen = false,
12
- timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
12
+ timeoutMs = BROWSER_AUTHORIZATION_TIMEOUT_MS,
13
13
  logger = console,
14
14
  now = Date.now,
15
15
  sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),