@awak-app/simy-cli 0.5.63 → 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.63",
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";
@@ -200,6 +202,22 @@ export async function startAgent({
200
202
  let organizationRefresh = null;
201
203
  let pendingOrganization = null;
202
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
+ });
203
221
  await cleanupExpiredRuns();
204
222
  for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
205
223
  localTaskRegistry.create(task);
@@ -471,8 +489,18 @@ export async function startAgent({
471
489
  return organizationRefresh;
472
490
  };
473
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
+ }
474
497
  // Reopening the verification page must keep the original nonce and scope.
475
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
+ }
476
504
  let organizationSlug = null;
477
505
  if (organizationId) {
478
506
  if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
@@ -495,9 +523,13 @@ export async function startAgent({
495
523
  }
496
524
  }
497
525
  if (!loginUrl) {
526
+ authorizationWindow.start();
527
+ expiredOrganizationId = null;
528
+ expiredInteractiveAuthorization = false;
498
529
  loginUrl = new URL("/local-cli/connect", apiOrigin);
499
530
  loginUrl.searchParams.set("port", String(port));
500
531
  loginUrl.searchParams.set("nonce", authNonce);
532
+ loginUrl.searchParams.set("auth_transport", BROWSER_HANDOFF_TRANSPORT);
501
533
  loginUrl.searchParams.set("cli_version", CLI_VERSION);
502
534
  loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
503
535
  if (installMode) loginUrl.searchParams.set("install_mode", installMode);
@@ -886,7 +918,11 @@ export async function startAgent({
886
918
 
887
919
  const server = createServer(async (req, res) => {
888
920
  try {
889
- 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)) {
890
926
  json(res, 403, { error: "request origin does not match the configured SIMY Web host" });
891
927
  return;
892
928
  }
@@ -896,9 +932,14 @@ export async function startAgent({
896
932
  return;
897
933
  }
898
934
 
899
- 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
+ }
900
941
  const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
901
- 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) ||
902
943
  req.method === 'POST' && (/^\/v1\/local-tasks\/[^/]+\/control$/.test(url.pathname) || /^\/v1\/agentic-loop\/[^/]+\/control$/.test(agenticLoopPath));
903
944
  let requestAccessScope;
904
945
  if (enforceDesktopAccess && !desktopRecoveryRoute) {
@@ -1120,106 +1161,127 @@ export async function startAgent({
1120
1161
  json(res, 200, environment);
1121
1162
  return;
1122
1163
  }
1123
- if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
1124
- const body = await readJson(req);
1125
- if (body.nonce !== authNonce) {
1126
- json(res, 401, { error: "invalid nonce" });
1127
- return;
1128
- }
1129
- if (body.api_origin !== apiOrigin) {
1130
- json(res, 403, { error: "authorization origin does not match the configured host" });
1131
- return;
1132
- }
1133
- 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;
1134
1167
  try {
1135
- apiBaseUrl = resolveWebApiBaseUrl(apiOrigin, body.api_base_url);
1136
- } catch (error) {
1137
- json(res, 503, {
1138
- error: error instanceof Error ? error.message : "CLI API endpoint is unavailable",
1139
- });
1140
- return;
1141
- }
1142
- const nextOrganization = body.organization_id ?? body.org_id;
1143
- const nextAccount = body.account_id ?? body.auth_user_id;
1144
- const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
1145
- if ((changingScope && organizationBusy()) || (pendingOrganization &&
1146
- (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
1147
- json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
1148
- return;
1149
- }
1150
- const previousSession = session ?? reclaimableSession;
1151
- reclaimableSession = null;
1152
- session = {
1153
- token: String(body.token || ""),
1154
- device_id: typeof body.device_id === "string" ? body.device_id : null,
1155
- api_origin: apiOrigin,
1156
- api_base_url: apiBaseUrl,
1157
- account_id: typeof body.account_id === "string"
1158
- ? body.account_id
1159
- : typeof body.auth_user_id === "string"
1160
- ? body.auth_user_id
1161
- : null,
1162
- organization_id: typeof body.organization_id === "string"
1163
- ? body.organization_id
1164
- : typeof body.org_id === "string"
1165
- ? body.org_id
1166
- : null,
1167
- expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
1168
- };
1169
- // A fresh sign-in issues a new device. Keep running as the device used
1170
- // before (its desktop WorkFlow evidence stays resumable) when this agent
1171
- // still holds that device's token. Otherwise continue as the new device.
1172
- if (
1173
- previousSession?.device_id &&
1174
- previousSession.token &&
1175
- session.device_id &&
1176
- previousSession.device_id !== session.device_id &&
1177
- previousSession.api_origin &&
1178
- (previousSession.account_id ?? null) === (session.account_id ?? null) &&
1179
- (previousSession.organization_id ?? null) === (session.organization_id ?? null) &&
1180
- previousApiBaseUrl(previousSession) === session.api_base_url
1181
- ) {
1168
+ const requestNonce = authNonce;
1169
+ let body;
1182
1170
  try {
1183
- const reclaimed = await reclaimDevice({
1184
- apiOrigin,
1185
- apiBaseUrl: session.api_base_url,
1186
- token: session.token,
1187
- previousDeviceId: previousSession.device_id,
1188
- previousToken: previousSession.token,
1189
- });
1190
- if (reclaimed?.device_id === previousSession.device_id) {
1191
- session.device_id = reclaimed.device_id;
1192
- if (typeof reclaimed.expires_at === "string") session.expires_at = reclaimed.expires_at;
1193
- }
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);
1194
1189
  } catch (error) {
1195
- if (!quiet) {
1196
- console.error(
1197
- `SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
1198
- );
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
+ }
1199
1252
  }
1200
1253
  }
1201
- }
1202
- await writeSession(apiOrigin, session, sessionRoot);
1203
- authNonce = randomBytes(16).toString("base64url");
1204
- pendingOrganization = null;
1205
- desktopLoginPending = false;
1206
- sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1207
- if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1208
- organizationState = "unknown";
1209
- organizationItems = [];
1210
- void refreshOrganizations();
1211
- refreshAvailableCapabilities();
1212
- startHeartbeatTimer();
1213
- loginUrl = null;
1214
- registry.emit("change", registry.list());
1215
- json(res, 200, {
1216
- ok: true,
1217
- expires_at: session.expires_at,
1218
- cli_version: CLI_VERSION,
1219
- api_contract_version: CLI_API_CONTRACT_VERSION,
1220
- });
1221
- void synchronizeAuthorizedSession();
1222
- 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; }
1223
1285
  }
1224
1286
  const localTaskArtifactMatch = url.pathname.match(
1225
1287
  /^\/v1\/local-tasks\/([^/]+)\/artifacts\/([^/]+)$/,
@@ -2203,6 +2265,7 @@ export async function startAgent({
2203
2265
  const shutdown = () => {
2204
2266
  if (shutdownPromise) return shutdownPromise;
2205
2267
  shuttingDown = true;
2268
+ authorizationWindow.clear();
2206
2269
  shutdownPromise = (async () => {
2207
2270
  if (heartbeatTimer) clearInterval(heartbeatTimer);
2208
2271
  if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
@@ -2234,6 +2297,7 @@ export async function startAgent({
2234
2297
  return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
2235
2298
  };
2236
2299
  const getDesktopStatus = async () => {
2300
+ const authorization = authorizationWindow.snapshot();
2237
2301
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
2238
2302
  if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
2239
2303
  if (enforceDesktopAccess) void desktopAccess.refresh().catch(() => {});
@@ -2251,7 +2315,9 @@ export async function startAgent({
2251
2315
  version: CLI_VERSION,
2252
2316
  webOrigin: apiOrigin,
2253
2317
  auth,
2254
- authorization: { pending: Boolean(pendingOrganization || desktopLoginPending) },
2318
+ authorization: { ...authorization,
2319
+ state: authorization.state === "timed_out" && !expiredInteractiveAuthorization ? "idle" : authorization.state,
2320
+ pending: Boolean(pendingOrganization || desktopLoginPending) },
2255
2321
  ...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
2256
2322
  connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
2257
2323
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
@@ -2284,7 +2350,11 @@ export async function startAgent({
2284
2350
  return requestDesktopLogin(null, { passive: true });
2285
2351
  },
2286
2352
  requestDesktopLogin,
2287
- getPendingAuthorizationUrl: () => pendingOrganization || desktopLoginPending ? loginUrl?.toString() ?? null : null,
2353
+ getPendingAuthorizationUrl: () => {
2354
+ authorizationWindow.snapshot();
2355
+ return pendingOrganization || desktopLoginPending ? loginUrl?.toString() ?? null : null;
2356
+ },
2357
+ retryAuthorization: () => authorizationWindow.snapshot().state === "timed_out" ? requestDesktopLogin() : null,
2288
2358
  async refreshDesktopAccess() {
2289
2359
  const previous = desktopAccess.snapshot().state;
2290
2360
  const result = await desktopAccess.refresh(true);
@@ -2296,7 +2366,7 @@ export async function startAgent({
2296
2366
  getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
2297
2367
  requireDesktopAccess: authorizeDesktopWork,
2298
2368
  refreshOrganizations,
2299
- cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; 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(); },
2300
2370
  async setSqmEnabled(enabled) {
2301
2371
  if (enabled) await authorizeDesktopWork();
2302
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.63";
16
+ export const CLI_VERSION = "0.5.64";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -442,7 +442,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
442
442
  if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
443
443
  throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
444
444
  }
445
- const url = action === "authorization_open" ? agent.getPendingAuthorizationUrl?.()
445
+ const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
446
446
  : await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
447
447
  if (action === 'authorization_open' && !url) throw new Error('No pending authorization.');
448
448
  if (url) {
@@ -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)),