@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 +7 -0
- package/package.json +1 -1
- package/src/agent.js +186 -104
- package/src/browser-authorization.js +32 -0
- package/src/browser-handoff.js +68 -0
- package/src/cli-contract.js +1 -1
- package/src/desktop-access.js +1 -1
- package/src/desktop-host.js +12 -5
- package/src/sqm/session.js +2 -2
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
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
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
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
16
|
+
export const CLI_VERSION = "0.5.64";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-access.js
CHANGED
|
@@ -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) {
|
package/src/desktop-host.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
448
|
-
|
|
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 () => {
|
package/src/sqm/session.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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)),
|