@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 +7 -0
- package/package.json +1 -1
- package/src/agent.js +171 -101
- package/src/browser-authorization.js +32 -0
- package/src/browser-handoff.js +68 -0
- package/src/cli-contract.js +1 -1
- package/src/desktop-host.js +1 -1
- 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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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" &&
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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: {
|
|
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: () =>
|
|
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
|
+
}
|
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-host.js
CHANGED
|
@@ -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) {
|
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)),
|