@topolo/cli 0.13.22 → 0.13.24
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 +17 -1
- package/dist/index.js +755 -202
- package/package.json +4 -4
- package/postinstall.mjs +6 -6
package/dist/index.js
CHANGED
|
@@ -308,7 +308,7 @@ import { Command, Help } from "commander";
|
|
|
308
308
|
import { createInterface } from "readline/promises";
|
|
309
309
|
import { stdin as input } from "process";
|
|
310
310
|
|
|
311
|
-
// node_modules/@topolo/sdk/dist/index.js
|
|
311
|
+
// node_modules/.pnpm/@topolo+sdk@0.23.26/node_modules/@topolo/sdk/dist/index.js
|
|
312
312
|
function record(value) {
|
|
313
313
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
314
314
|
}
|
|
@@ -815,6 +815,14 @@ var TopoloRateLimitError = class extends TopoloHttpError {
|
|
|
815
815
|
this.retryAfterSeconds = topoloRetryAfterSeconds(this.retryAfter);
|
|
816
816
|
}
|
|
817
817
|
};
|
|
818
|
+
var TopoloReauthRequiredError = class extends TopoloAuthError {
|
|
819
|
+
reauthorization;
|
|
820
|
+
constructor(message3, reauthorization, options = {}) {
|
|
821
|
+
super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
|
|
822
|
+
this.name = "TopoloReauthRequiredError";
|
|
823
|
+
this.reauthorization = Object.freeze({ ...reauthorization });
|
|
824
|
+
}
|
|
825
|
+
};
|
|
818
826
|
var ACTION_SECRET_PREFIX = "topolo-secret:";
|
|
819
827
|
function isActionSecretReference(value) {
|
|
820
828
|
return typeof value === "string" && value.startsWith(ACTION_SECRET_PREFIX);
|
|
@@ -1968,7 +1976,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1968
1976
|
} catch (error) {
|
|
1969
1977
|
const transportFailure = !(error instanceof TopoloSdkError) && !opts.signal?.aborted;
|
|
1970
1978
|
if (!transportFailure || attempt === maxAttempts) throw error;
|
|
1971
|
-
await new Promise((
|
|
1979
|
+
await new Promise((resolve6) => setTimeout(resolve6, 250));
|
|
1972
1980
|
}
|
|
1973
1981
|
}
|
|
1974
1982
|
throw new TopoloSdkError("network_error", "Topolo read retry loop exhausted.");
|
|
@@ -1991,7 +1999,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1991
1999
|
const delayMs = header === void 0 ? RATE_LIMIT_READ_DEFAULT_DELAY_MS * (retry + 1) : (seconds ?? Infinity) * 1e3;
|
|
1992
2000
|
if (delayMs > RATE_LIMIT_READ_RETRY_MAX_DELAY_MS || opts.signal?.aborted) break;
|
|
1993
2001
|
await res.body?.cancel().catch(() => void 0);
|
|
1994
|
-
await new Promise((
|
|
2002
|
+
await new Promise((resolve6) => setTimeout(resolve6, delayMs));
|
|
1995
2003
|
res = await fetchWithReadRetry();
|
|
1996
2004
|
}
|
|
1997
2005
|
} catch (err) {
|
|
@@ -2115,7 +2123,10 @@ var TopoloClient = class _TopoloClient {
|
|
|
2115
2123
|
this.refreshInFlight ??= this.refreshCredential().then((credential) => {
|
|
2116
2124
|
if (credential) this.credential = credential;
|
|
2117
2125
|
return credential;
|
|
2118
|
-
}).catch(() =>
|
|
2126
|
+
}).catch((error) => {
|
|
2127
|
+
if (error instanceof TopoloReauthRequiredError) throw error;
|
|
2128
|
+
return null;
|
|
2129
|
+
}).finally(() => {
|
|
2119
2130
|
this.refreshInFlight = null;
|
|
2120
2131
|
});
|
|
2121
2132
|
return this.refreshInFlight;
|
|
@@ -3489,6 +3500,19 @@ var TopoloOAuth = class {
|
|
|
3489
3500
|
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
3490
3501
|
return this.#postForm("/api/developer-oauth/token", body);
|
|
3491
3502
|
}
|
|
3503
|
+
/**
|
|
3504
|
+
* RFC 7009 token revocation. A refresh token is revoked only for the client
|
|
3505
|
+
* it was issued to, so pass that client's id. Auth answers 200 for a token it
|
|
3506
|
+
* does not know, so success means "no longer usable", not "was revoked now".
|
|
3507
|
+
*/
|
|
3508
|
+
async revokeToken(params) {
|
|
3509
|
+
const body = new URLSearchParams();
|
|
3510
|
+
body.set("token", params.token);
|
|
3511
|
+
body.set("client_id", params.clientId);
|
|
3512
|
+
if (params.tokenTypeHint) body.set("token_type_hint", params.tokenTypeHint);
|
|
3513
|
+
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
3514
|
+
await this.#postForm("/api/tokens/revoke", body);
|
|
3515
|
+
}
|
|
3492
3516
|
async #postForm(path, body) {
|
|
3493
3517
|
const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
|
|
3494
3518
|
const res = await this.fetchImpl(url.toString(), {
|
|
@@ -3989,7 +4013,7 @@ function createTopolo(options) {
|
|
|
3989
4013
|
async function pollUntilApproved(params) {
|
|
3990
4014
|
let interval = Math.max(params.intervalSeconds ?? 5, 1);
|
|
3991
4015
|
while (Date.now() < params.expiresAtMs) {
|
|
3992
|
-
await new Promise((
|
|
4016
|
+
await new Promise((resolve6) => setTimeout(resolve6, Math.min(interval * 1e3, params.expiresAtMs - Date.now())));
|
|
3993
4017
|
if (Date.now() >= params.expiresAtMs) break;
|
|
3994
4018
|
try {
|
|
3995
4019
|
return await params.oauth.pollDeviceToken({
|
|
@@ -4017,32 +4041,16 @@ async function pollUntilApproved(params) {
|
|
|
4017
4041
|
throw new Error("Device code expired. Run `topolo auth login` again.");
|
|
4018
4042
|
}
|
|
4019
4043
|
|
|
4020
|
-
// src/
|
|
4021
|
-
import {
|
|
4022
|
-
import { platform } from "os";
|
|
4023
|
-
function tryOpenBrowser(url) {
|
|
4024
|
-
try {
|
|
4025
|
-
const os = platform();
|
|
4026
|
-
const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
|
4027
|
-
const args = os === "win32" ? ["", url] : [url];
|
|
4028
|
-
const child = spawn(cmd, args, {
|
|
4029
|
-
detached: true,
|
|
4030
|
-
stdio: "ignore",
|
|
4031
|
-
...os === "win32" ? { shell: true } : {}
|
|
4032
|
-
});
|
|
4033
|
-
child.unref();
|
|
4034
|
-
return true;
|
|
4035
|
-
} catch {
|
|
4036
|
-
return false;
|
|
4037
|
-
}
|
|
4038
|
-
}
|
|
4044
|
+
// src/commands/auth-lock.ts
|
|
4045
|
+
import { createHash as createHash3 } from "crypto";
|
|
4039
4046
|
|
|
4040
|
-
// node_modules/@topolo/sdk/dist/node/credential-store.js
|
|
4047
|
+
// node_modules/.pnpm/@topolo+sdk@0.23.26/node_modules/@topolo/sdk/dist/node/credential-store.js
|
|
4041
4048
|
import { mkdir as mkdir2, readFile as readFile2, chmod as chmod2, lstat, readdir as readdir2 } from "fs/promises";
|
|
4042
4049
|
import { existsSync as existsSync2, mkdirSync, lstatSync, readdirSync, readFileSync as readFileSync2, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, rmdirSync, linkSync } from "fs";
|
|
4043
4050
|
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
4044
4051
|
import { constants, fstatSync, readSync } from "fs";
|
|
4045
|
-
import {
|
|
4052
|
+
import { resolve as resolve2 } from "path";
|
|
4053
|
+
import { homedir as homedir2, platform } from "os";
|
|
4046
4054
|
import { dirname as dirname2, join as join3 } from "path";
|
|
4047
4055
|
import { createHash, randomUUID } from "crypto";
|
|
4048
4056
|
import { join as join2 } from "path";
|
|
@@ -4370,7 +4378,7 @@ var TopoloRateLimitError2 = class extends TopoloHttpError2 {
|
|
|
4370
4378
|
this.retryAfterSeconds = topoloRetryAfterSeconds2(this.retryAfter);
|
|
4371
4379
|
}
|
|
4372
4380
|
};
|
|
4373
|
-
var
|
|
4381
|
+
var TopoloReauthRequiredError2 = class extends TopoloAuthError2 {
|
|
4374
4382
|
reauthorization;
|
|
4375
4383
|
constructor(message3, reauthorization, options = {}) {
|
|
4376
4384
|
super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
|
|
@@ -4520,6 +4528,19 @@ var TopoloOAuth2 = class {
|
|
|
4520
4528
|
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
4521
4529
|
return this.#postForm("/api/developer-oauth/token", body);
|
|
4522
4530
|
}
|
|
4531
|
+
/**
|
|
4532
|
+
* RFC 7009 token revocation. A refresh token is revoked only for the client
|
|
4533
|
+
* it was issued to, so pass that client's id. Auth answers 200 for a token it
|
|
4534
|
+
* does not know, so success means "no longer usable", not "was revoked now".
|
|
4535
|
+
*/
|
|
4536
|
+
async revokeToken(params) {
|
|
4537
|
+
const body = new URLSearchParams();
|
|
4538
|
+
body.set("token", params.token);
|
|
4539
|
+
body.set("client_id", params.clientId);
|
|
4540
|
+
if (params.tokenTypeHint) body.set("token_type_hint", params.tokenTypeHint);
|
|
4541
|
+
if (params.clientSecret) body.set("client_secret", params.clientSecret);
|
|
4542
|
+
await this.#postForm("/api/tokens/revoke", body);
|
|
4543
|
+
}
|
|
4523
4544
|
async #postForm(path, body) {
|
|
4524
4545
|
const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
|
|
4525
4546
|
const res = await this.fetchImpl(url.toString(), {
|
|
@@ -4810,7 +4831,7 @@ var ENVIRONMENT_DEFAULT_SERVICE_URLS = TOPOLO_ENVIRONMENT_SERVICE_URLS;
|
|
|
4810
4831
|
function configPath() {
|
|
4811
4832
|
const override = process.env.TOPOLO_CONFIG_PATH;
|
|
4812
4833
|
if (override) return override;
|
|
4813
|
-
const base = process.env.XDG_CONFIG_HOME || (
|
|
4834
|
+
const base = process.env.XDG_CONFIG_HOME || (platform() === "win32" ? join3(homedir2(), "AppData", "Roaming") : join3(homedir2(), ".config"));
|
|
4814
4835
|
return join3(base, "topolo", "config.json");
|
|
4815
4836
|
}
|
|
4816
4837
|
async function readConfig() {
|
|
@@ -4818,18 +4839,22 @@ async function readConfig() {
|
|
|
4818
4839
|
if (!existsSync2(path)) return {};
|
|
4819
4840
|
try {
|
|
4820
4841
|
const raw = await readFile2(path, "utf8");
|
|
4821
|
-
|
|
4842
|
+
const parsed = JSON.parse(raw);
|
|
4843
|
+
return isConfigObject(parsed) ? parsed : {};
|
|
4822
4844
|
} catch {
|
|
4823
4845
|
return {};
|
|
4824
4846
|
}
|
|
4825
4847
|
}
|
|
4848
|
+
function isConfigObject(value) {
|
|
4849
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
4850
|
+
}
|
|
4826
4851
|
async function writeConfig(config) {
|
|
4827
4852
|
const path = configPath();
|
|
4828
4853
|
await mkdir2(dirname2(path), { recursive: true });
|
|
4829
4854
|
await withConfigWriteLock(async (_assertOwned, persist) => {
|
|
4830
4855
|
await persist((current) => protectStoredGrants(config, current), { allowCreate: true });
|
|
4831
4856
|
});
|
|
4832
|
-
if (
|
|
4857
|
+
if (platform() !== "win32") {
|
|
4833
4858
|
try {
|
|
4834
4859
|
await chmod2(path, 384);
|
|
4835
4860
|
} catch {
|
|
@@ -4854,10 +4879,21 @@ function retiredEntry(config, refreshToken) {
|
|
|
4854
4879
|
const entry = config.retiredRefreshTokens?.[refreshTokenFingerprint(refreshToken)];
|
|
4855
4880
|
return entry && typeof entry === "object" ? entry : void 0;
|
|
4856
4881
|
}
|
|
4857
|
-
function retire(config, refreshToken, reason) {
|
|
4882
|
+
function retire(config, refreshToken, reason, inFlight = false) {
|
|
4858
4883
|
const now = nowSeconds();
|
|
4859
|
-
const
|
|
4860
|
-
config.
|
|
4884
|
+
const entry = inFlight ? { reason, at: now, inFlight: true } : { reason, at: now };
|
|
4885
|
+
const held = new Set(allSlots(config).map(slotRefreshToken).filter((token) => Boolean(token)).map(refreshTokenFingerprint));
|
|
4886
|
+
const valid = Object.entries({ ...config.retiredRefreshTokens ?? {}, [refreshTokenFingerprint(refreshToken)]: entry }).filter(([key, value]) => /^[a-f0-9]{32}$/.test(key) && value && Number.isFinite(value.at));
|
|
4887
|
+
const kept = valid.filter(([key]) => held.has(key));
|
|
4888
|
+
const others = valid.filter(([key, value]) => !held.has(key) && value.at > now - RETIRED_RETENTION_SECONDS).sort(([, a], [, b]) => b.at - a.at).slice(0, Math.max(0, RETIRED_MAX_ENTRIES - kept.length));
|
|
4889
|
+
config.retiredRefreshTokens = Object.fromEntries([...kept, ...others]);
|
|
4890
|
+
}
|
|
4891
|
+
function clearInFlight(config, refreshToken) {
|
|
4892
|
+
const fingerprint = refreshTokenFingerprint(refreshToken);
|
|
4893
|
+
if (!config.retiredRefreshTokens?.[fingerprint]?.inFlight) return;
|
|
4894
|
+
const { [fingerprint]: _withdrawn, ...rest } = config.retiredRefreshTokens;
|
|
4895
|
+
if (Object.keys(rest).length) config.retiredRefreshTokens = rest;
|
|
4896
|
+
else delete config.retiredRefreshTokens;
|
|
4861
4897
|
}
|
|
4862
4898
|
function environmentSlots(config, environment) {
|
|
4863
4899
|
const slots = [];
|
|
@@ -4939,6 +4975,20 @@ function loginCommand(environment, key) {
|
|
|
4939
4975
|
const use = environment === "production" ? "topolo context use" : `topolo --env ${environment} context use`;
|
|
4940
4976
|
return `${base} && ${use} ${key.slice(4)}`;
|
|
4941
4977
|
}
|
|
4978
|
+
function slotUsers(profile, key) {
|
|
4979
|
+
const context = key === DEFAULT_SLOT ? void 0 : profile.contexts?.[key];
|
|
4980
|
+
const credentials = [context?.credential, key === DEFAULT_SLOT || profile.activeContext === key ? profile.credential : void 0];
|
|
4981
|
+
const users = [
|
|
4982
|
+
context?.userId,
|
|
4983
|
+
...credentials.map((credential) => credential?.kind === "access_token" ? accessTokenSubject(credential.accessToken) : void 0),
|
|
4984
|
+
profile.reauthorization?.[key]?.expectedSubject
|
|
4985
|
+
];
|
|
4986
|
+
return new Set(users.filter((user) => typeof user === "string" && user.length > 0));
|
|
4987
|
+
}
|
|
4988
|
+
function mismatchState(entry) {
|
|
4989
|
+
if (!entry.mismatch) return "unavailable";
|
|
4990
|
+
return entry.mismatch.differentUser ? "user_mismatch" : "organization_mismatch";
|
|
4991
|
+
}
|
|
4942
4992
|
function contextLabel(key) {
|
|
4943
4993
|
return key === DEFAULT_SLOT ? "default" : key;
|
|
4944
4994
|
}
|
|
@@ -4950,6 +5000,8 @@ function reauthRequired(environment, key, entry, state) {
|
|
|
4950
5000
|
let action;
|
|
4951
5001
|
if (state === "awaiting_approval" && pending) {
|
|
4952
5002
|
action = ` Approve the new sign-in: open ${pending.verificationUriComplete ?? pending.verificationUri} and confirm code ${pending.userCode} (expires ${new Date(pending.expiresAt * 1e3).toISOString()}). The next call resumes once approved.`;
|
|
5003
|
+
} else if (state === "user_mismatch") {
|
|
5004
|
+
action = ` The last approval was by a different user than the one signed in to ${contextLabel(key)}; it was discarded. Approve again as that user, or sign out first to replace this sign-in.`;
|
|
4953
5005
|
} else if (state === "organization_mismatch") {
|
|
4954
5006
|
action = ` The last approval was for ${entry?.mismatch?.approvedContext ?? "another context"}, not ${contextLabel(key)}; approve again with that organization selected.`;
|
|
4955
5007
|
} else if (state === "denied") {
|
|
@@ -4957,7 +5009,7 @@ function reauthRequired(environment, key, entry, state) {
|
|
|
4957
5009
|
} else {
|
|
4958
5010
|
action = "";
|
|
4959
5011
|
}
|
|
4960
|
-
return new
|
|
5012
|
+
return new TopoloReauthRequiredError2(`${head}${action} Or sign in again: ${command} (context ${contextLabel(key)}).`, {
|
|
4961
5013
|
environment,
|
|
4962
5014
|
context: contextLabel(key),
|
|
4963
5015
|
reason,
|
|
@@ -5035,18 +5087,94 @@ function writeContextCredential(config, environment, ctx, makeActive = true) {
|
|
|
5035
5087
|
}
|
|
5036
5088
|
return writeProfileConfig(config, environment, next);
|
|
5037
5089
|
}
|
|
5038
|
-
function
|
|
5039
|
-
const next =
|
|
5090
|
+
function clearEnvironmentSignIn(config, environment) {
|
|
5091
|
+
const next = clone(config);
|
|
5040
5092
|
if (environment === "production") {
|
|
5041
5093
|
delete next.credential;
|
|
5042
5094
|
delete next.oauthClientId;
|
|
5043
5095
|
}
|
|
5044
|
-
const
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5096
|
+
const profile = next.profiles?.[environment];
|
|
5097
|
+
if (profile) {
|
|
5098
|
+
delete profile.credential;
|
|
5099
|
+
delete profile.oauthClientId;
|
|
5100
|
+
delete profile.activeContext;
|
|
5101
|
+
delete profile.contexts;
|
|
5102
|
+
delete profile.reauthorization;
|
|
5103
|
+
}
|
|
5104
|
+
return next;
|
|
5105
|
+
}
|
|
5106
|
+
async function signOut(environment, options = {}) {
|
|
5107
|
+
const result = { environment, contexts: [], revoked: 0, revocationFailed: 0 };
|
|
5108
|
+
if (!existsSync2(configPath())) return result;
|
|
5109
|
+
let grants = [];
|
|
5110
|
+
let profile = {};
|
|
5111
|
+
const collect = (config, found, keys) => {
|
|
5112
|
+
const owner = profileForEnvironment(config, environment);
|
|
5113
|
+
for (const slot of environmentSlots(config, environment)) {
|
|
5114
|
+
const credential = slot.holder.credential;
|
|
5115
|
+
if (!credential) continue;
|
|
5116
|
+
keys?.add(slot.key);
|
|
5117
|
+
if (credential.kind !== "access_token") continue;
|
|
5118
|
+
const clientId = slot.holder.oauthClientId ?? owner.oauthClientId;
|
|
5119
|
+
found.set(credential.accessToken, {
|
|
5120
|
+
accessToken: credential.accessToken,
|
|
5121
|
+
...credential.refreshToken ? { refreshToken: credential.refreshToken } : {},
|
|
5122
|
+
...clientId ? { clientId } : {}
|
|
5123
|
+
});
|
|
5124
|
+
}
|
|
5125
|
+
};
|
|
5126
|
+
await withConfigWriteLock(async (_assertOwned, persist) => {
|
|
5127
|
+
let found = /* @__PURE__ */ new Map();
|
|
5128
|
+
await persist((current) => {
|
|
5129
|
+
profile = profileForEnvironment(current, environment);
|
|
5130
|
+
const keys = /* @__PURE__ */ new Set();
|
|
5131
|
+
found = /* @__PURE__ */ new Map();
|
|
5132
|
+
collect(current, found, keys);
|
|
5133
|
+
for (const key of Object.keys(current.profiles?.[environment]?.reauthorization ?? {})) keys.add(key);
|
|
5134
|
+
result.contexts = [...keys].map(contextLabel).sort();
|
|
5135
|
+
return clearEnvironmentSignIn(current, environment);
|
|
5136
|
+
});
|
|
5137
|
+
const directory = dirname2(configPath());
|
|
5138
|
+
for (const name of readdirSync(directory)) {
|
|
5139
|
+
if (!name.startsWith(".credential-write-")) continue;
|
|
5140
|
+
const path = join3(directory, name);
|
|
5141
|
+
try {
|
|
5142
|
+
const entry = lstatSync(path);
|
|
5143
|
+
if (!entry.isFile() || entry.nlink !== 1) continue;
|
|
5144
|
+
try {
|
|
5145
|
+
const leftover = JSON.parse(readFileSync2(path, "utf8"));
|
|
5146
|
+
if (isConfigObject(leftover)) collect(leftover, found);
|
|
5147
|
+
} catch {
|
|
5148
|
+
}
|
|
5149
|
+
unlinkSync(path);
|
|
5150
|
+
} catch {
|
|
5151
|
+
}
|
|
5152
|
+
}
|
|
5153
|
+
grants = [...found.values()];
|
|
5154
|
+
});
|
|
5155
|
+
if (options.revoke === false || !grants.length) return result;
|
|
5156
|
+
let oauth;
|
|
5157
|
+
try {
|
|
5158
|
+
oauth = boundedOAuth(authServiceUrls(profile, environment, "sign-out"));
|
|
5159
|
+
} catch {
|
|
5160
|
+
result.revocationFailed = grants.length;
|
|
5161
|
+
return result;
|
|
5162
|
+
}
|
|
5163
|
+
const outcomes = await Promise.all(grants.map(async (grant) => {
|
|
5164
|
+
try {
|
|
5165
|
+
if (!grant.clientId) return false;
|
|
5166
|
+
if (grant.refreshToken) {
|
|
5167
|
+
await oauth.revokeToken({ clientId: grant.clientId, token: grant.refreshToken, tokenTypeHint: "refresh_token" });
|
|
5168
|
+
}
|
|
5169
|
+
await oauth.revokeToken({ clientId: grant.clientId, token: grant.accessToken, tokenTypeHint: "access_token" });
|
|
5170
|
+
return true;
|
|
5171
|
+
} catch {
|
|
5172
|
+
return false;
|
|
5173
|
+
}
|
|
5174
|
+
}));
|
|
5175
|
+
result.revoked = outcomes.filter(Boolean).length;
|
|
5176
|
+
result.revocationFailed = outcomes.length - result.revoked;
|
|
5177
|
+
return result;
|
|
5050
5178
|
}
|
|
5051
5179
|
function credentialSourceFromConfig(config, environment = selectedEnvironment()) {
|
|
5052
5180
|
const prefix = envVarPrefix(environment);
|
|
@@ -5069,8 +5197,8 @@ async function resolveCredential(environment = selectedEnvironment()) {
|
|
|
5069
5197
|
}
|
|
5070
5198
|
var REFRESH_SKEW_SECONDS = 60;
|
|
5071
5199
|
var CONFIG_LOCK_RETRY_MS = 25;
|
|
5072
|
-
var CONFIG_LOCK_DEFAULT_TIMEOUT_MS =
|
|
5073
|
-
var AUTH_EXCHANGE_TIMEOUT_MS =
|
|
5200
|
+
var CONFIG_LOCK_DEFAULT_TIMEOUT_MS = 3e4;
|
|
5201
|
+
var AUTH_EXCHANGE_TIMEOUT_MS = 2e4;
|
|
5074
5202
|
function configLockTimeoutMs() {
|
|
5075
5203
|
const value = Number(process.env.TOPOLO_CONFIG_LOCK_TIMEOUT_MS);
|
|
5076
5204
|
return Number.isFinite(value) && value >= 1e3 && value <= 12e4 ? value : CONFIG_LOCK_DEFAULT_TIMEOUT_MS;
|
|
@@ -5078,6 +5206,8 @@ function configLockTimeoutMs() {
|
|
|
5078
5206
|
function boundedOAuth(serviceUrls) {
|
|
5079
5207
|
const fetchImpl = (input2, init) => globalThis.fetch(input2, {
|
|
5080
5208
|
...init,
|
|
5209
|
+
// Never re-POST a refresh token, device code or revoked token to another origin.
|
|
5210
|
+
redirect: "error",
|
|
5081
5211
|
signal: init?.signal ?? AbortSignal.timeout(AUTH_EXCHANGE_TIMEOUT_MS)
|
|
5082
5212
|
});
|
|
5083
5213
|
return new TopoloOAuth2({ serviceUrls, fetch: fetchImpl });
|
|
@@ -5126,6 +5256,61 @@ function readOwnerMarker(lockPath, name) {
|
|
|
5126
5256
|
if (owner.version !== 1 || Object.keys(owner).sort().join(",") !== "identity,pid,version" || name !== "owner-" + owner.identity + ".json" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) throw new ConfigOwnershipError();
|
|
5127
5257
|
return { bytes, pid: owner.pid };
|
|
5128
5258
|
}
|
|
5259
|
+
function inspectConfigLockAt(path, ignoreOwnFence = false) {
|
|
5260
|
+
const lockPath = path + ".lock";
|
|
5261
|
+
let state = "UNKNOWN";
|
|
5262
|
+
let reason = "UNVERIFIED_CUSTODY";
|
|
5263
|
+
let witness = "unverified";
|
|
5264
|
+
try {
|
|
5265
|
+
const parent = lstatSync(dirname2(lockPath));
|
|
5266
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.() || parent.mode & 18) throw new ConfigOwnershipError();
|
|
5267
|
+
const parentIdentity = [parent.dev, parent.ino, parent.uid, parent.mode];
|
|
5268
|
+
if (!existsSync2(lockPath)) {
|
|
5269
|
+
try {
|
|
5270
|
+
lstatSync(lockPath);
|
|
5271
|
+
throw new ConfigOwnershipError();
|
|
5272
|
+
} catch (error) {
|
|
5273
|
+
if (error.code !== "ENOENT") throw error;
|
|
5274
|
+
}
|
|
5275
|
+
state = "FREE";
|
|
5276
|
+
reason = "ABSENT";
|
|
5277
|
+
witness = [parentIdentity, "absent"];
|
|
5278
|
+
} else {
|
|
5279
|
+
const directory = privateLockDirectory(lockPath);
|
|
5280
|
+
const entries = readdirSync(lockPath).sort();
|
|
5281
|
+
witness = [parentIdentity, lockIdentity(directory), entries];
|
|
5282
|
+
if (entries.length === 0) reason = "EMPTY_OWNER_UNKNOWN";
|
|
5283
|
+
else if (entries.length === 1 && OWNER_MARKER_NAME.test(entries[0])) {
|
|
5284
|
+
const { bytes, pid } = readOwnerMarker(lockPath, entries[0]);
|
|
5285
|
+
witness = [witness, createHash2("sha256").update(bytes).digest("hex")];
|
|
5286
|
+
try {
|
|
5287
|
+
process.kill(pid, 0);
|
|
5288
|
+
state = "LIVE";
|
|
5289
|
+
reason = "OWNER_PID_EXISTS";
|
|
5290
|
+
} catch {
|
|
5291
|
+
}
|
|
5292
|
+
}
|
|
5293
|
+
if (JSON.stringify(lockIdentity(lstatSync(lockPath))) !== JSON.stringify(lockIdentity(directory)) || JSON.stringify(readdirSync(lockPath).sort()) !== JSON.stringify(entries)) throw new ConfigOwnershipError();
|
|
5294
|
+
}
|
|
5295
|
+
if (!ignoreOwnFence && lockEntryExists(lockPath + ".reconcile")) {
|
|
5296
|
+
state = "UNKNOWN";
|
|
5297
|
+
reason = "RECONCILIATION_IN_PROGRESS";
|
|
5298
|
+
}
|
|
5299
|
+
} catch {
|
|
5300
|
+
state = "UNKNOWN";
|
|
5301
|
+
reason = "UNVERIFIED_CUSTODY";
|
|
5302
|
+
}
|
|
5303
|
+
return {
|
|
5304
|
+
configPath: path,
|
|
5305
|
+
lockPath,
|
|
5306
|
+
state,
|
|
5307
|
+
reason,
|
|
5308
|
+
generation: createHash2("sha256").update(JSON.stringify([path, witness])).digest("hex")
|
|
5309
|
+
};
|
|
5310
|
+
}
|
|
5311
|
+
async function inspectConfigLock() {
|
|
5312
|
+
return inspectConfigLockAt(resolve2(configPath()));
|
|
5313
|
+
}
|
|
5129
5314
|
function writePrivateLockRecord(path, bytes) {
|
|
5130
5315
|
const fd = openSync(path, "wx", 384);
|
|
5131
5316
|
try {
|
|
@@ -5135,6 +5320,107 @@ function writePrivateLockRecord(path, bytes) {
|
|
|
5135
5320
|
closeSync(fd);
|
|
5136
5321
|
}
|
|
5137
5322
|
}
|
|
5323
|
+
async function reconcileConfigLock(input2) {
|
|
5324
|
+
const path = resolve2(configPath());
|
|
5325
|
+
const before = inspectConfigLockAt(path);
|
|
5326
|
+
if (!input2 || typeof input2 !== "object") throw new ConfigOwnershipError();
|
|
5327
|
+
const inputFields = Object.getOwnPropertyDescriptors(input2);
|
|
5328
|
+
if (Reflect.ownKeys(inputFields).length !== 2 || typeof inputFields.expectedGeneration?.value !== "string" || !inputFields.review || !Object.prototype.hasOwnProperty.call(inputFields.review, "value")) throw new ConfigOwnershipError();
|
|
5329
|
+
const supplied = inputFields.review.value;
|
|
5330
|
+
if (!supplied || typeof supplied !== "object") throw new ConfigOwnershipError();
|
|
5331
|
+
const fields = Object.getOwnPropertyDescriptors(supplied);
|
|
5332
|
+
const names = ["kind", "configPath", "generation", "quiescenceEvidenceSha256", "reviewedBy", "expiresAt"];
|
|
5333
|
+
if (Reflect.ownKeys(fields).length !== names.length || names.some((name) => !fields[name] || !Object.prototype.hasOwnProperty.call(fields[name], "value") || typeof fields[name].value !== "string")) throw new ConfigOwnershipError();
|
|
5334
|
+
const expectedGeneration = inputFields.expectedGeneration.value;
|
|
5335
|
+
const review = Object.freeze({
|
|
5336
|
+
kind: fields.kind.value,
|
|
5337
|
+
configPath: fields.configPath.value,
|
|
5338
|
+
generation: fields.generation.value,
|
|
5339
|
+
quiescenceEvidenceSha256: fields.quiescenceEvidenceSha256.value,
|
|
5340
|
+
reviewedBy: fields.reviewedBy.value,
|
|
5341
|
+
expiresAt: fields.expiresAt.value
|
|
5342
|
+
});
|
|
5343
|
+
const expiry = Date.parse(review.expiresAt);
|
|
5344
|
+
if (review.kind !== "quiescent-empty-quarantine" || review.configPath !== path || review.generation !== expectedGeneration || before.generation !== expectedGeneration || !/^[a-f0-9]{64}$/.test(review.quiescenceEvidenceSha256) || typeof review.reviewedBy !== "string" || !/^[a-zA-Z0-9_.:-]{1,80}$/.test(review.reviewedBy) || !Number.isFinite(expiry) || expiry <= Date.now() || expiry > Date.now() + 15 * 6e4 || before.state !== "UNKNOWN" || before.reason !== "EMPTY_OWNER_UNKNOWN") throw new ConfigOwnershipError();
|
|
5345
|
+
const lockPath = before.lockPath;
|
|
5346
|
+
const fence = lockPath + ".reconcile";
|
|
5347
|
+
const identity = randomUUID2();
|
|
5348
|
+
const markerName = "owner-" + identity + ".json";
|
|
5349
|
+
const marker = JSON.stringify({ version: 1, identity, pid: process.pid });
|
|
5350
|
+
const quarantine = join3(dirname2(lockPath), ".credential-reconcile-" + identity);
|
|
5351
|
+
const captured = join3(quarantine, "lock");
|
|
5352
|
+
const receiptPath = join3(quarantine, "receipt.json");
|
|
5353
|
+
let capturedLock = false;
|
|
5354
|
+
let preserveFence = false;
|
|
5355
|
+
try {
|
|
5356
|
+
mkdirSync(fence, { mode: 448 });
|
|
5357
|
+
} catch {
|
|
5358
|
+
throw new ConfigOwnershipError();
|
|
5359
|
+
}
|
|
5360
|
+
const fenceDirectory = privateLockDirectory(fence);
|
|
5361
|
+
const fenceMarker = join3(fence, markerName);
|
|
5362
|
+
const assertFence = () => {
|
|
5363
|
+
const entry = privateLockDirectory(fence);
|
|
5364
|
+
if (entry.dev !== fenceDirectory.dev || entry.ino !== fenceDirectory.ino || readdirSync(fence).length !== 1 || readdirSync(fence)[0] !== markerName || readPrivateLockMarker(fenceMarker) !== marker) throw new ConfigOwnershipError();
|
|
5365
|
+
};
|
|
5366
|
+
try {
|
|
5367
|
+
writePrivateLockRecord(fenceMarker, marker);
|
|
5368
|
+
assertFence();
|
|
5369
|
+
const current = inspectConfigLockAt(path, true);
|
|
5370
|
+
if (current.state !== "UNKNOWN" || current.reason !== "EMPTY_OWNER_UNKNOWN" || current.generation !== before.generation || Date.now() >= expiry) throw new ConfigOwnershipError();
|
|
5371
|
+
const directory = privateLockDirectory(lockPath);
|
|
5372
|
+
mkdirSync(quarantine, { mode: 448 });
|
|
5373
|
+
writePrivateLockRecord(receiptPath, JSON.stringify({
|
|
5374
|
+
schemaVersion: 1,
|
|
5375
|
+
disposition: "PENDING_CAPTURE",
|
|
5376
|
+
classificationBefore: "UNKNOWN",
|
|
5377
|
+
before,
|
|
5378
|
+
review,
|
|
5379
|
+
directoryIdentity: [directory.dev, directory.ino, directory.uid, directory.mode],
|
|
5380
|
+
quiescence: "caller-reviewed; legacy and bare writers not globally enforced by SDK",
|
|
5381
|
+
configCredentialOrOAuthEffectsByThisAPI: "none"
|
|
5382
|
+
}, null, 2));
|
|
5383
|
+
assertFence();
|
|
5384
|
+
if (inspectConfigLockAt(path, true).generation !== before.generation || Date.now() >= expiry) throw new ConfigOwnershipError();
|
|
5385
|
+
renameSync(lockPath, captured);
|
|
5386
|
+
capturedLock = true;
|
|
5387
|
+
const entry = privateLockDirectory(captured);
|
|
5388
|
+
if (entry.dev !== directory.dev || entry.ino !== directory.ino || readdirSync(captured).length || lockEntryExists(lockPath) || Date.now() >= expiry) throw new ConfigOwnershipError();
|
|
5389
|
+
assertFence();
|
|
5390
|
+
writePrivateLockRecord(join3(quarantine, "outcome.json"), JSON.stringify({
|
|
5391
|
+
disposition: "CAPTURE_VERIFIED_PENDING_FENCE_CLOSURE",
|
|
5392
|
+
classificationBefore: "UNKNOWN",
|
|
5393
|
+
fenceClosureVerified: false,
|
|
5394
|
+
noDeathOrPriorRemoteEffectResolutionInferred: true
|
|
5395
|
+
}));
|
|
5396
|
+
} catch (error) {
|
|
5397
|
+
if (!capturedLock) throw error instanceof ConfigOwnershipError ? error : new ConfigOwnershipError();
|
|
5398
|
+
preserveFence = true;
|
|
5399
|
+
return { outcome: "UNRESOLVED_CAPTURE", before, quarantinePath: quarantine, receiptPath, fenceRetained: true };
|
|
5400
|
+
} finally {
|
|
5401
|
+
if (!preserveFence) {
|
|
5402
|
+
try {
|
|
5403
|
+
assertFence();
|
|
5404
|
+
const capturedMarker = join3(quarantine, markerName);
|
|
5405
|
+
if (!lockEntryExists(quarantine)) mkdirSync(quarantine, { mode: 448 });
|
|
5406
|
+
renameSync(fenceMarker, capturedMarker);
|
|
5407
|
+
const entry = privateLockDirectory(fence);
|
|
5408
|
+
if (entry.dev !== fenceDirectory.dev || entry.ino !== fenceDirectory.ino || readdirSync(fence).length || readPrivateLockMarker(capturedMarker) !== marker) {
|
|
5409
|
+
try {
|
|
5410
|
+
linkSync(capturedMarker, fenceMarker);
|
|
5411
|
+
} catch {
|
|
5412
|
+
}
|
|
5413
|
+
throw new ConfigOwnershipError();
|
|
5414
|
+
}
|
|
5415
|
+
rmdirSync(fence);
|
|
5416
|
+
} catch {
|
|
5417
|
+
if (capturedLock) return { outcome: "UNRESOLVED_CAPTURE", before, quarantinePath: quarantine, receiptPath, fenceRetained: true };
|
|
5418
|
+
throw new ConfigOwnershipError();
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
return { outcome: "QUARANTINED_UNKNOWN_EMPTY", before, quarantinePath: quarantine, receiptPath, fenceRetained: false };
|
|
5423
|
+
}
|
|
5138
5424
|
function deadOwnerMarker(lockPath, ignoreOwnFence = false) {
|
|
5139
5425
|
try {
|
|
5140
5426
|
if (!ignoreOwnFence && lockEntryExists(lockPath + ".reconcile")) return null;
|
|
@@ -5209,6 +5495,12 @@ function reclaimDeadOwnerLock(lockPath, markerName, marker) {
|
|
|
5209
5495
|
return reclaimed;
|
|
5210
5496
|
}
|
|
5211
5497
|
var RELEASE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
5498
|
+
function unreadableConfig(path) {
|
|
5499
|
+
return new TopoloSdkError2(
|
|
5500
|
+
"config_unreadable",
|
|
5501
|
+
`The Topolo configuration at ${path} is not a valid JSON object, so it was left unchanged. Repair or move it, then retry.`
|
|
5502
|
+
);
|
|
5503
|
+
}
|
|
5212
5504
|
var PERSIST_ATTEMPTS = 3;
|
|
5213
5505
|
async function withConfigWriteLock(operation) {
|
|
5214
5506
|
const lockPath = `${configPath()}.lock`;
|
|
@@ -5291,11 +5583,14 @@ async function withConfigWriteLock(operation) {
|
|
|
5291
5583
|
const before = snapshot();
|
|
5292
5584
|
let current = {};
|
|
5293
5585
|
if (before !== null) {
|
|
5586
|
+
let parsed = {};
|
|
5294
5587
|
try {
|
|
5295
|
-
|
|
5296
|
-
} catch
|
|
5297
|
-
|
|
5588
|
+
if (before.trim()) parsed = JSON.parse(before);
|
|
5589
|
+
} catch {
|
|
5590
|
+
throw unreadableConfig(path);
|
|
5298
5591
|
}
|
|
5592
|
+
if (!isConfigObject(parsed)) throw unreadableConfig(path);
|
|
5593
|
+
current = parsed;
|
|
5299
5594
|
}
|
|
5300
5595
|
const updated = update(current);
|
|
5301
5596
|
const fd = openSync(temporary, "wx", 384);
|
|
@@ -5377,7 +5672,8 @@ async function withConfigWriteLock(operation) {
|
|
|
5377
5672
|
function renewalFailure(error) {
|
|
5378
5673
|
const path = "/api/developer-oauth/token";
|
|
5379
5674
|
if (error instanceof ConfigOwnershipError) return new ConfigOwnershipError();
|
|
5380
|
-
if (error instanceof
|
|
5675
|
+
if (error instanceof TopoloReauthRequiredError2) return error;
|
|
5676
|
+
if (error instanceof TopoloSdkError2 && error.code === "config_unreadable") return error;
|
|
5381
5677
|
if ((error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) && error.status === 429) {
|
|
5382
5678
|
const message3 = "Topolo sign-in renewal is temporarily rate limited by Auth. Retry after the Retry-After interval; the saved sign-in has been retained.";
|
|
5383
5679
|
return new TopoloRateLimitError2(
|
|
@@ -5453,6 +5749,35 @@ function singleFlight(key, run) {
|
|
|
5453
5749
|
renewalsInFlight.set(key, started);
|
|
5454
5750
|
return started;
|
|
5455
5751
|
}
|
|
5752
|
+
var GATEWAY_STATUSES = /* @__PURE__ */ new Set([502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
|
|
5753
|
+
var NOT_SENT_CODES = /* @__PURE__ */ new Set([
|
|
5754
|
+
"ENOTFOUND",
|
|
5755
|
+
"EAI_AGAIN",
|
|
5756
|
+
"EAI_NONAME",
|
|
5757
|
+
"ECONNREFUSED",
|
|
5758
|
+
"ENETUNREACH",
|
|
5759
|
+
"EHOSTUNREACH",
|
|
5760
|
+
"EADDRNOTAVAIL",
|
|
5761
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
5762
|
+
"CERT_HAS_EXPIRED",
|
|
5763
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
5764
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
5765
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
5766
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
5767
|
+
]);
|
|
5768
|
+
function answeredWithoutRotation(error) {
|
|
5769
|
+
if (error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) return !GATEWAY_STATUSES.has(Number(error.status));
|
|
5770
|
+
for (let cause = error, depth = 0; cause && depth < 4; cause = cause.cause, depth += 1) {
|
|
5771
|
+
const code = cause.code;
|
|
5772
|
+
if (typeof code === "string" && NOT_SENT_CODES.has(code)) return true;
|
|
5773
|
+
}
|
|
5774
|
+
return false;
|
|
5775
|
+
}
|
|
5776
|
+
function usableTokenResponse(tokens) {
|
|
5777
|
+
if (!tokens || typeof tokens !== "object") return false;
|
|
5778
|
+
const value = tokens;
|
|
5779
|
+
return typeof value.access_token === "string" && value.access_token.length > 0 && typeof value.expires_in === "number" && Number.isFinite(value.expires_in) && (value.refresh_token === void 0 || typeof value.refresh_token === "string" && value.refresh_token.length > 0);
|
|
5780
|
+
}
|
|
5456
5781
|
async function renewTarget(environment, target, options) {
|
|
5457
5782
|
const observedProfile = profileForEnvironment(await readConfig(), environment);
|
|
5458
5783
|
const observedTarget = resolveTarget(observedProfile, target);
|
|
@@ -5474,6 +5799,7 @@ async function renewTarget(environment, target, options) {
|
|
|
5474
5799
|
if (options.force && observed.kind === "access_token" && observed.accessToken !== credential.accessToken && credential.expiresAt !== void 0 && credential.expiresAt > now) return { credential };
|
|
5475
5800
|
if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return { credential };
|
|
5476
5801
|
const sent = credential.refreshToken;
|
|
5802
|
+
const subject = accessTokenSubject(credential.accessToken) ?? resolved.context?.userId;
|
|
5477
5803
|
const endGrant = async (reason) => {
|
|
5478
5804
|
await persist((current) => {
|
|
5479
5805
|
const next = clone(current);
|
|
@@ -5481,35 +5807,67 @@ async function renewTarget(environment, target, options) {
|
|
|
5481
5807
|
touched.add(resolved.key);
|
|
5482
5808
|
retire(next, sent, reason);
|
|
5483
5809
|
for (const key of touched) {
|
|
5484
|
-
setReauthorization(next, environment, key, (existing) => ({
|
|
5810
|
+
setReauthorization(next, environment, key, (existing) => ({
|
|
5811
|
+
...subject ? { expectedSubject: subject } : {},
|
|
5812
|
+
...existing,
|
|
5813
|
+
reason,
|
|
5814
|
+
since: existing?.since ?? nowSeconds()
|
|
5815
|
+
}));
|
|
5485
5816
|
}
|
|
5486
5817
|
return next;
|
|
5487
5818
|
});
|
|
5488
5819
|
return { reauthorize: resolved.key };
|
|
5489
5820
|
};
|
|
5821
|
+
const outcomeUnknown = async () => {
|
|
5822
|
+
if (!options.force && credential.expiresAt && credential.expiresAt > nowSeconds()) return { credential };
|
|
5823
|
+
return endGrant("rotated_grant_lost");
|
|
5824
|
+
};
|
|
5490
5825
|
const retired = retiredEntry(config, sent);
|
|
5491
5826
|
if (retired) {
|
|
5492
5827
|
return endGrant(retired.reason === "rotated" ? "rotated_grant_lost" : retired.reason);
|
|
5493
5828
|
}
|
|
5494
5829
|
const serviceUrls = authServiceUrls(profile, environment, target.kind === "active" ? "credential refresh" : "context refresh");
|
|
5830
|
+
const oauth = boundedOAuth(serviceUrls);
|
|
5831
|
+
try {
|
|
5832
|
+
await persist((current) => {
|
|
5833
|
+
const next = clone(current);
|
|
5834
|
+
retire(next, sent, "rotated", true);
|
|
5835
|
+
return next;
|
|
5836
|
+
});
|
|
5837
|
+
} catch (error) {
|
|
5838
|
+
throw error instanceof TopoloSdkError2 ? renewalFailure(error) : new TopoloSdkError2("config_write_failed", "The Topolo configuration could not be updated, so the sign-in was not renewed. Nothing was sent to Auth; the saved sign-in has been retained.");
|
|
5839
|
+
}
|
|
5495
5840
|
let tokens;
|
|
5496
5841
|
try {
|
|
5497
|
-
tokens = await
|
|
5842
|
+
tokens = await oauth.refreshToken({ clientId: resolved.oauthClientId, refreshToken: sent });
|
|
5498
5843
|
} catch (error) {
|
|
5499
5844
|
if (isTerminalRefusal(error)) return endGrant(error.code);
|
|
5845
|
+
if (!answeredWithoutRotation(error)) return outcomeUnknown();
|
|
5846
|
+
try {
|
|
5847
|
+
await persist((current) => {
|
|
5848
|
+
const next = clone(current);
|
|
5849
|
+
clearInFlight(next, sent);
|
|
5850
|
+
return next;
|
|
5851
|
+
});
|
|
5852
|
+
} catch (persistError) {
|
|
5853
|
+
throw renewalFailure(persistError);
|
|
5854
|
+
}
|
|
5500
5855
|
throw renewalFailure(error);
|
|
5501
5856
|
}
|
|
5857
|
+
if (!usableTokenResponse(tokens)) return outcomeUnknown();
|
|
5858
|
+
const issued = tokens;
|
|
5502
5859
|
const refreshed = {
|
|
5503
5860
|
kind: "access_token",
|
|
5504
|
-
accessToken:
|
|
5505
|
-
refreshToken:
|
|
5506
|
-
expiresAt: nowSeconds() +
|
|
5861
|
+
accessToken: issued.access_token,
|
|
5862
|
+
refreshToken: issued.refresh_token ?? sent,
|
|
5863
|
+
expiresAt: nowSeconds() + issued.expires_in
|
|
5507
5864
|
};
|
|
5508
5865
|
try {
|
|
5509
5866
|
await persist((current) => {
|
|
5510
5867
|
const next = clone(current);
|
|
5511
5868
|
replaceGrant(next, environment, sent, refreshed);
|
|
5512
|
-
if (
|
|
5869
|
+
if (issued.refresh_token && issued.refresh_token !== sent) retire(next, sent, "rotated");
|
|
5870
|
+
else clearInFlight(next, sent);
|
|
5513
5871
|
return next;
|
|
5514
5872
|
});
|
|
5515
5873
|
} catch (error) {
|
|
@@ -5529,17 +5887,30 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), op
|
|
|
5529
5887
|
async function refreshContextCredentialIfNeeded(environment, key, options = {}) {
|
|
5530
5888
|
return renewTarget(environment, { kind: "context", key }, options);
|
|
5531
5889
|
}
|
|
5532
|
-
function
|
|
5890
|
+
function accessTokenClaims(accessToken) {
|
|
5533
5891
|
try {
|
|
5534
|
-
const payload = accessToken
|
|
5535
|
-
if (!payload) return
|
|
5892
|
+
const payload = accessToken?.split(".")[1];
|
|
5893
|
+
if (!payload) return null;
|
|
5536
5894
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
5537
|
-
|
|
5538
|
-
return { known: true, organizationId: typeof org === "string" && org ? org : null };
|
|
5895
|
+
return claims && typeof claims === "object" && !Array.isArray(claims) ? claims : null;
|
|
5539
5896
|
} catch {
|
|
5540
|
-
return
|
|
5897
|
+
return null;
|
|
5541
5898
|
}
|
|
5542
5899
|
}
|
|
5900
|
+
function accessTokenSubject(accessToken) {
|
|
5901
|
+
const sub = accessTokenClaims(accessToken)?.sub;
|
|
5902
|
+
return typeof sub === "string" && sub ? sub : void 0;
|
|
5903
|
+
}
|
|
5904
|
+
function accessTokenExpiry(accessToken) {
|
|
5905
|
+
const exp = accessTokenClaims(accessToken)?.exp;
|
|
5906
|
+
return typeof exp === "number" && Number.isSafeInteger(exp) && exp > 0 ? exp : void 0;
|
|
5907
|
+
}
|
|
5908
|
+
function decodeTokenOrganization(accessToken) {
|
|
5909
|
+
const claims = accessTokenClaims(accessToken);
|
|
5910
|
+
if (!claims) return { known: false, organizationId: null };
|
|
5911
|
+
const org = claims.orgId ?? claims.organizationId ?? claims.org_id ?? null;
|
|
5912
|
+
return { known: true, organizationId: typeof org === "string" && org ? org : null };
|
|
5913
|
+
}
|
|
5543
5914
|
async function continueReauthorization(environment, key) {
|
|
5544
5915
|
return withConfigWriteLock(async (_assertOwned, persist) => {
|
|
5545
5916
|
const config = await readConfig();
|
|
@@ -5596,7 +5967,21 @@ async function continueReauthorization(environment, key) {
|
|
|
5596
5967
|
};
|
|
5597
5968
|
const organization = decodeTokenOrganization(tokens.access_token);
|
|
5598
5969
|
const approvedKey = organization.known ? organization.organizationId ? `org:${organization.organizationId}` : "personal" : null;
|
|
5970
|
+
const approvedSubject = accessTokenSubject(tokens.access_token);
|
|
5599
5971
|
const matches = key === DEFAULT_SLOT || approvedKey === key;
|
|
5972
|
+
const destination = matches ? key : approvedKey;
|
|
5973
|
+
const destinationUsers = destination ? slotUsers(profile, destination) : /* @__PURE__ */ new Set();
|
|
5974
|
+
if (state.expectedSubject) destinationUsers.add(state.expectedSubject);
|
|
5975
|
+
if ([...destinationUsers].some((user) => user !== approvedSubject)) {
|
|
5976
|
+
const updated = {
|
|
5977
|
+
...state,
|
|
5978
|
+
mismatch: { approvedContext: "a different user", at: nowSeconds(), differentUser: true }
|
|
5979
|
+
};
|
|
5980
|
+
delete updated.pending;
|
|
5981
|
+
await save(() => updated);
|
|
5982
|
+
throw reauthRequired(environment, key, updated, "user_mismatch");
|
|
5983
|
+
}
|
|
5984
|
+
const signedInAs = approvedSubject ? { userId: approvedSubject } : {};
|
|
5600
5985
|
await persist((current) => {
|
|
5601
5986
|
const next = clone(current);
|
|
5602
5987
|
const nextProfile = (next.profiles ??= {})[environment] ??= {};
|
|
@@ -5610,7 +5995,8 @@ async function continueReauthorization(environment, key) {
|
|
|
5610
5995
|
nextProfile.contexts = { ...nextProfile.contexts ?? {}, [key]: {
|
|
5611
5996
|
...existing ?? (key.startsWith("org:") ? { contextType: "organization", organizationId: key.slice(4) } : { contextType: "personal" }),
|
|
5612
5997
|
credential: approved,
|
|
5613
|
-
oauthClientId: clientId
|
|
5998
|
+
oauthClientId: clientId,
|
|
5999
|
+
...signedInAs
|
|
5614
6000
|
} };
|
|
5615
6001
|
if (nextProfile.activeContext === key) nextProfile.credential = approved;
|
|
5616
6002
|
}
|
|
@@ -5621,7 +6007,8 @@ async function continueReauthorization(environment, key) {
|
|
|
5621
6007
|
nextProfile.contexts = { ...nextProfile.contexts ?? {}, [approvedKey]: {
|
|
5622
6008
|
...existing ?? (approvedKey.startsWith("org:") ? { contextType: "organization", organizationId: approvedKey.slice(4) } : { contextType: "personal" }),
|
|
5623
6009
|
credential: approved,
|
|
5624
|
-
oauthClientId: clientId
|
|
6010
|
+
oauthClientId: clientId,
|
|
6011
|
+
...signedInAs
|
|
5625
6012
|
} };
|
|
5626
6013
|
if (nextProfile.activeContext === approvedKey) nextProfile.credential = approved;
|
|
5627
6014
|
}
|
|
@@ -5645,7 +6032,7 @@ async function continueReauthorization(environment, key) {
|
|
|
5645
6032
|
throw reauthRequired(environment, key, state, pending.denied ? "denied" : "awaiting_approval");
|
|
5646
6033
|
}
|
|
5647
6034
|
if (state.startRetryAt && state.startRetryAt > now) {
|
|
5648
|
-
throw reauthRequired(environment, key, state, state
|
|
6035
|
+
throw reauthRequired(environment, key, state, mismatchState(state));
|
|
5649
6036
|
}
|
|
5650
6037
|
try {
|
|
5651
6038
|
const started = await oauth.requestDeviceCode({ clientId });
|
|
@@ -5666,7 +6053,7 @@ async function continueReauthorization(environment, key) {
|
|
|
5666
6053
|
await save(() => state);
|
|
5667
6054
|
throw reauthRequired(environment, key, state, "awaiting_approval");
|
|
5668
6055
|
} catch (error) {
|
|
5669
|
-
if (error instanceof
|
|
6056
|
+
if (error instanceof TopoloReauthRequiredError2 || error instanceof ConfigOwnershipError) throw error;
|
|
5670
6057
|
const retryAfter = Number(error.retryAfter);
|
|
5671
6058
|
state = { ...state, startRetryAt: now + Math.max(30, Number.isFinite(retryAfter) ? retryAfter : 0) };
|
|
5672
6059
|
delete state.pending;
|
|
@@ -5684,7 +6071,7 @@ async function awaitReauthorization(environment, key, options = {}) {
|
|
|
5684
6071
|
try {
|
|
5685
6072
|
return await continueReauthorization(environment, slotKey);
|
|
5686
6073
|
} catch (error) {
|
|
5687
|
-
if (!(error instanceof
|
|
6074
|
+
if (!(error instanceof TopoloReauthRequiredError2)) throw error;
|
|
5688
6075
|
const prompt = error.reauthorization;
|
|
5689
6076
|
if (prompt.userCode !== lastCode) {
|
|
5690
6077
|
lastCode = prompt.userCode;
|
|
@@ -5732,110 +6119,6 @@ function resolvedProfileConfig(config, environment = selectedEnvironment()) {
|
|
|
5732
6119
|
};
|
|
5733
6120
|
}
|
|
5734
6121
|
|
|
5735
|
-
// src/config.ts
|
|
5736
|
-
async function authenticatedEnvironments(configOrPromise = readConfig()) {
|
|
5737
|
-
const config = await configOrPromise;
|
|
5738
|
-
const found = /* @__PURE__ */ new Set();
|
|
5739
|
-
if (config.credential) found.add("production");
|
|
5740
|
-
for (const environment of TOPOLO_ENVIRONMENTS) {
|
|
5741
|
-
const profile = config.profiles?.[environment];
|
|
5742
|
-
if (!profile) continue;
|
|
5743
|
-
if (profile.credential || Object.keys(profile.contexts ?? {}).length > 0) {
|
|
5744
|
-
found.add(environment);
|
|
5745
|
-
}
|
|
5746
|
-
}
|
|
5747
|
-
return TOPOLO_ENVIRONMENTS.filter((environment) => found.has(environment));
|
|
5748
|
-
}
|
|
5749
|
-
function environmentLabel(environment = selectedEnvironment()) {
|
|
5750
|
-
return environment;
|
|
5751
|
-
}
|
|
5752
|
-
function developerConsoleUrl(environment = selectedEnvironment()) {
|
|
5753
|
-
if (environment === "production") return "https://developers.topolo.app";
|
|
5754
|
-
if (environment === "development") return "https://developers.topolo.dev";
|
|
5755
|
-
return "https://developers.stg.topolo.us";
|
|
5756
|
-
}
|
|
5757
|
-
function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
|
|
5758
|
-
return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
|
|
5759
|
-
}
|
|
5760
|
-
function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
|
|
5761
|
-
return `${developerConsoleUrl(environment)}/credentials`;
|
|
5762
|
-
}
|
|
5763
|
-
var DEFAULT_CLI_CLIENT_IDS = {
|
|
5764
|
-
production: "topolo-cli",
|
|
5765
|
-
staging: "topolo-cli",
|
|
5766
|
-
development: "topolo-cli"
|
|
5767
|
-
};
|
|
5768
|
-
function resolveOAuthClientId(config, environment = selectedEnvironment()) {
|
|
5769
|
-
const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
|
|
5770
|
-
if (envSpecific) return envSpecific;
|
|
5771
|
-
if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
|
|
5772
|
-
return DEFAULT_CLI_CLIENT_IDS[environment] ?? profileForEnvironment(config, environment).oauthClientId;
|
|
5773
|
-
}
|
|
5774
|
-
function signedInContextKeys(config, environment) {
|
|
5775
|
-
return Object.keys(profileForEnvironment(config, environment).contexts ?? {});
|
|
5776
|
-
}
|
|
5777
|
-
function matchContextSelector(config, environment, selector) {
|
|
5778
|
-
const normalized = selector.trim().toLowerCase();
|
|
5779
|
-
if (!normalized || /[\r\n]/.test(selector)) {
|
|
5780
|
-
throw new Error("Context must be a non-empty single-line identifier.");
|
|
5781
|
-
}
|
|
5782
|
-
const contexts = Object.entries(profileForEnvironment(config, environment).contexts ?? {});
|
|
5783
|
-
const exact = contexts.find(([key]) => key.toLowerCase() === normalized);
|
|
5784
|
-
if (exact) return exact[0];
|
|
5785
|
-
const matches = contexts.filter(([, context]) => [
|
|
5786
|
-
context.organizationId,
|
|
5787
|
-
context.organizationSlug,
|
|
5788
|
-
context.organizationName
|
|
5789
|
-
].some((value) => String(value || "").trim().toLowerCase() === normalized));
|
|
5790
|
-
if (matches.length > 1) {
|
|
5791
|
-
throw new Error(`Context "${selector.trim()}" is ambiguous in ${environment}; use an exact key from \`topolo --env ${environment} context ls\`.`);
|
|
5792
|
-
}
|
|
5793
|
-
return matches[0]?.[0] ?? null;
|
|
5794
|
-
}
|
|
5795
|
-
function commandContextKey(config, environment, explicit = process.env.TOPOLO_CONTEXT) {
|
|
5796
|
-
const selector = explicit?.trim();
|
|
5797
|
-
if (selector) return matchContextSelector(config, environment, selector) ?? selector;
|
|
5798
|
-
return implicitContextKey(config, environment) ?? void 0;
|
|
5799
|
-
}
|
|
5800
|
-
function implicitContextKey(config, environment) {
|
|
5801
|
-
if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
|
|
5802
|
-
const keys = signedInContextKeys(config, environment);
|
|
5803
|
-
if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
|
|
5804
|
-
return keys[0] ?? null;
|
|
5805
|
-
}
|
|
5806
|
-
var ContextNotSafeToAssumeError = class extends Error {
|
|
5807
|
-
constructor(environment, contexts) {
|
|
5808
|
-
super(
|
|
5809
|
-
`This machine is signed in to ${contexts.length} Topolo contexts in ${environment} (${contexts.join(", ")}), so the context is no longer safe to assume.
|
|
5810
|
-
|
|
5811
|
-
State it explicitly:
|
|
5812
|
-
topolo --env ${environment} --context <${contexts.join("|")}> ...
|
|
5813
|
-
|
|
5814
|
-
Or set it once for the whole session:
|
|
5815
|
-
export TOPOLO_CONTEXT=<${contexts.join("|")}>`
|
|
5816
|
-
);
|
|
5817
|
-
this.environment = environment;
|
|
5818
|
-
this.contexts = contexts;
|
|
5819
|
-
this.name = "ContextNotSafeToAssumeError";
|
|
5820
|
-
}
|
|
5821
|
-
environment;
|
|
5822
|
-
contexts;
|
|
5823
|
-
};
|
|
5824
|
-
async function withPinnedContext(options, read = () => readConfig()) {
|
|
5825
|
-
if (options.context?.trim() || options.installationId?.trim()) return options;
|
|
5826
|
-
const environment = normalizeEnvironment(options.environment?.trim() || process.env.TOPOLO_ENV || "production");
|
|
5827
|
-
const keys = signedInContextKeys(await read(), environment);
|
|
5828
|
-
if (keys.length === 1) return { ...options, context: keys[0] };
|
|
5829
|
-
if (!keys.length) {
|
|
5830
|
-
throw new Error(
|
|
5831
|
-
`No Topolo context is signed in for ${environment}, so there is nothing to pin. Run \`topolo --env ${environment} auth login\` first, or pass --context <context>.`
|
|
5832
|
-
);
|
|
5833
|
-
}
|
|
5834
|
-
throw new Error(
|
|
5835
|
-
`${keys.length} Topolo contexts are signed in for ${environment} (${keys.join(", ")}). Pass --context <context> to choose the one this MCP registration pins.`
|
|
5836
|
-
);
|
|
5837
|
-
}
|
|
5838
|
-
|
|
5839
6122
|
// src/output.ts
|
|
5840
6123
|
function resolveOutputMode(override) {
|
|
5841
6124
|
if (override === true) return "json";
|
|
@@ -5895,6 +6178,7 @@ function normalizeError(err) {
|
|
|
5895
6178
|
};
|
|
5896
6179
|
const body = err.body;
|
|
5897
6180
|
const status = err.status;
|
|
6181
|
+
const localLockConflict = code === "state_conflict" && status === void 0 && body === void 0 && err.service === void 0;
|
|
5898
6182
|
const suppliedDetails = err.details;
|
|
5899
6183
|
return {
|
|
5900
6184
|
message: status !== void 0 || body !== void 0 ? publicTopoloErrorMessage({ code, body, status, message: err.message }) : err.message,
|
|
@@ -5907,7 +6191,7 @@ function normalizeError(err) {
|
|
|
5907
6191
|
// SDK HTTP errors already carry sanitized details; projecting them again
|
|
5908
6192
|
// would drop form-level issues that have no field path.
|
|
5909
6193
|
details: reauthorizationDetails(err) ?? (err instanceof TopoloHttpError || err instanceof TopoloAuthError || err instanceof TopoloPermissionError ? err.details : extractTopoloServiceErrorDetails(suppliedDetails === void 0 ? body : { details: suppliedDetails })),
|
|
5910
|
-
hint: reauthorizationHint(err) ?? topoloErrorHint({ code, body }) ?? (code ? fallbackHintMap[code] : void 0),
|
|
6194
|
+
hint: reauthorizationHint(err) ?? topoloErrorHint({ code, body }) ?? (localLockConflict ? "The saved sign-in is locked by another or an interrupted Topolo process. Run `topolo auth lock` to see who holds it and how to recover." : code ? fallbackHintMap[code] : void 0),
|
|
5911
6195
|
exitCode: status === 401 || code === "auth_error" || code === "confirmation_required" ? 2 : status === 403 || code === "permission_denied" ? 3 : code === "immutable_drift" ? 4 : 1
|
|
5912
6196
|
};
|
|
5913
6197
|
}
|
|
@@ -5968,9 +6252,214 @@ function readCliVersion() {
|
|
|
5968
6252
|
}
|
|
5969
6253
|
var CLI_VERSION = readCliVersion();
|
|
5970
6254
|
|
|
6255
|
+
// src/commands/auth-lock.ts
|
|
6256
|
+
var QUIESCENCE_WAIT_MS = 2e3;
|
|
6257
|
+
var REVIEW_TTL_MS = 5 * 6e4;
|
|
6258
|
+
function lockGuidance(diagnostic) {
|
|
6259
|
+
const { state, reason, lockPath, generation } = diagnostic;
|
|
6260
|
+
if (state === "FREE") return "No lock is held. Sign-in renewal is not blocked.";
|
|
6261
|
+
if (state === "LIVE") {
|
|
6262
|
+
return `A running process holds the lock; a renewal normally finishes within 10 seconds. If it stays held after every Topolo CLI and MCP process has exited (after a restart the recorded process number can belong to another program), quit them all and move ${lockPath} aside.`;
|
|
6263
|
+
}
|
|
6264
|
+
if (reason === "EMPTY_OWNER_UNKNOWN") {
|
|
6265
|
+
return `An empty lock was left by an interrupted writer. Close other Topolo CLI and MCP processes, then run:
|
|
6266
|
+
topolo auth lock --reconcile ${generation}`;
|
|
6267
|
+
}
|
|
6268
|
+
if (reason === "RECONCILIATION_IN_PROGRESS") {
|
|
6269
|
+
return `A lock recovery is running or was interrupted (${lockPath}.reconcile). Wait for it, or if no Topolo process is running, keep the fence for review and move both paths aside.`;
|
|
6270
|
+
}
|
|
6271
|
+
return `The lock's owner cannot be verified on this system (unexpected owner, permissions or platform). If no Topolo CLI or MCP process is running, move ${lockPath} aside.`;
|
|
6272
|
+
}
|
|
6273
|
+
function registerAuthLockCommand(auth) {
|
|
6274
|
+
auth.command("lock").description("Inspect, and recover, the lock that serializes sign-in renewal").option("--reconcile <generation>", "Quarantine an empty lock left by an interrupted writer (generation from `topolo auth lock`)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
|
|
6275
|
+
const mode = resolveOutputMode(opts.json);
|
|
6276
|
+
try {
|
|
6277
|
+
const diagnostic = await inspectConfigLock();
|
|
6278
|
+
if (opts.reconcile === void 0) {
|
|
6279
|
+
emit(
|
|
6280
|
+
{ lock: diagnostic, guidance: lockGuidance(diagnostic) },
|
|
6281
|
+
mode,
|
|
6282
|
+
() => [
|
|
6283
|
+
`Lock: ${diagnostic.lockPath}`,
|
|
6284
|
+
`State: ${diagnostic.state} (${diagnostic.reason})`,
|
|
6285
|
+
`Generation: ${diagnostic.generation}`,
|
|
6286
|
+
"",
|
|
6287
|
+
lockGuidance(diagnostic)
|
|
6288
|
+
].join("\n")
|
|
6289
|
+
);
|
|
6290
|
+
return;
|
|
6291
|
+
}
|
|
6292
|
+
const generation = opts.reconcile.trim();
|
|
6293
|
+
if (diagnostic.state !== "UNKNOWN" || diagnostic.reason !== "EMPTY_OWNER_UNKNOWN") {
|
|
6294
|
+
throw new Error(`Only an empty lock left by an interrupted writer can be reconciled; this lock is ${diagnostic.state} (${diagnostic.reason}). ${lockGuidance(diagnostic)}`);
|
|
6295
|
+
}
|
|
6296
|
+
if (diagnostic.generation !== generation) {
|
|
6297
|
+
throw new Error("The lock changed since it was inspected. Run `topolo auth lock` again and use the generation it prints.");
|
|
6298
|
+
}
|
|
6299
|
+
await new Promise((resolve6) => setTimeout(resolve6, QUIESCENCE_WAIT_MS));
|
|
6300
|
+
const settled = await inspectConfigLock();
|
|
6301
|
+
if (settled.generation !== generation) {
|
|
6302
|
+
throw new Error("The lock changed while waiting, so another process is using it. Nothing was changed.");
|
|
6303
|
+
}
|
|
6304
|
+
const evidence = JSON.stringify({
|
|
6305
|
+
observer: `topolo-cli/${CLI_VERSION}`,
|
|
6306
|
+
before: diagnostic,
|
|
6307
|
+
after: settled,
|
|
6308
|
+
waitedMs: QUIESCENCE_WAIT_MS
|
|
6309
|
+
});
|
|
6310
|
+
const result = await reconcileConfigLock({
|
|
6311
|
+
expectedGeneration: generation,
|
|
6312
|
+
review: {
|
|
6313
|
+
kind: "quiescent-empty-quarantine",
|
|
6314
|
+
configPath: diagnostic.configPath,
|
|
6315
|
+
generation,
|
|
6316
|
+
quiescenceEvidenceSha256: createHash3("sha256").update(evidence).digest("hex"),
|
|
6317
|
+
reviewedBy: "topolo-cli:auth-lock",
|
|
6318
|
+
expiresAt: new Date(Date.now() + REVIEW_TTL_MS).toISOString()
|
|
6319
|
+
}
|
|
6320
|
+
});
|
|
6321
|
+
const recovered = result.outcome === "QUARANTINED_UNKNOWN_EMPTY";
|
|
6322
|
+
if (!recovered) process.exitCode = 1;
|
|
6323
|
+
emit(
|
|
6324
|
+
{ ok: recovered, outcome: result.outcome, quarantinePath: result.quarantinePath, fenceRetained: result.fenceRetained },
|
|
6325
|
+
mode,
|
|
6326
|
+
() => recovered ? `Recovered: the empty lock was moved to ${result.quarantinePath}. Sign-in renewal can proceed.` : `Not recovered: the lock changed during capture. Evidence and the recovery fence were kept in ${result.quarantinePath} for review.`
|
|
6327
|
+
);
|
|
6328
|
+
} catch (error) {
|
|
6329
|
+
fail2(error, mode);
|
|
6330
|
+
}
|
|
6331
|
+
});
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6334
|
+
// src/lib/open-browser.ts
|
|
6335
|
+
import { spawn } from "child_process";
|
|
6336
|
+
import { platform as platform2 } from "os";
|
|
6337
|
+
function tryOpenBrowser(url) {
|
|
6338
|
+
try {
|
|
6339
|
+
const os = platform2();
|
|
6340
|
+
const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
|
|
6341
|
+
const args = os === "win32" ? ["", url] : [url];
|
|
6342
|
+
const child = spawn(cmd, args, {
|
|
6343
|
+
detached: true,
|
|
6344
|
+
stdio: "ignore",
|
|
6345
|
+
...os === "win32" ? { shell: true } : {}
|
|
6346
|
+
});
|
|
6347
|
+
child.unref();
|
|
6348
|
+
return true;
|
|
6349
|
+
} catch {
|
|
6350
|
+
return false;
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
// src/config.ts
|
|
6355
|
+
async function authenticatedEnvironments(configOrPromise = readConfig()) {
|
|
6356
|
+
const config = await configOrPromise;
|
|
6357
|
+
const found = /* @__PURE__ */ new Set();
|
|
6358
|
+
if (config.credential) found.add("production");
|
|
6359
|
+
for (const environment of TOPOLO_ENVIRONMENTS) {
|
|
6360
|
+
const profile = config.profiles?.[environment];
|
|
6361
|
+
if (!profile) continue;
|
|
6362
|
+
if (profile.credential || Object.keys(profile.contexts ?? {}).length > 0) {
|
|
6363
|
+
found.add(environment);
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
return TOPOLO_ENVIRONMENTS.filter((environment) => found.has(environment));
|
|
6367
|
+
}
|
|
6368
|
+
function environmentLabel(environment = selectedEnvironment()) {
|
|
6369
|
+
return environment;
|
|
6370
|
+
}
|
|
6371
|
+
function developerConsoleUrl(environment = selectedEnvironment()) {
|
|
6372
|
+
if (environment === "production") return "https://developers.topolo.app";
|
|
6373
|
+
if (environment === "development") return "https://developers.topolo.dev";
|
|
6374
|
+
return "https://developers.stg.topolo.us";
|
|
6375
|
+
}
|
|
6376
|
+
function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
|
|
6377
|
+
return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
|
|
6378
|
+
}
|
|
6379
|
+
function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
|
|
6380
|
+
return `${developerConsoleUrl(environment)}/credentials`;
|
|
6381
|
+
}
|
|
6382
|
+
var DEFAULT_CLI_CLIENT_IDS = {
|
|
6383
|
+
production: "topolo-cli",
|
|
6384
|
+
staging: "topolo-cli",
|
|
6385
|
+
development: "topolo-cli"
|
|
6386
|
+
};
|
|
6387
|
+
function resolveOAuthClientId(config, environment = selectedEnvironment()) {
|
|
6388
|
+
const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
|
|
6389
|
+
if (envSpecific) return envSpecific;
|
|
6390
|
+
if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
|
|
6391
|
+
return DEFAULT_CLI_CLIENT_IDS[environment] ?? profileForEnvironment(config, environment).oauthClientId;
|
|
6392
|
+
}
|
|
6393
|
+
function signedInContextKeys(config, environment) {
|
|
6394
|
+
return Object.keys(profileForEnvironment(config, environment).contexts ?? {});
|
|
6395
|
+
}
|
|
6396
|
+
function matchContextSelector(config, environment, selector) {
|
|
6397
|
+
const normalized = selector.trim().toLowerCase();
|
|
6398
|
+
if (!normalized || /[\r\n]/.test(selector)) {
|
|
6399
|
+
throw new Error("Context must be a non-empty single-line identifier.");
|
|
6400
|
+
}
|
|
6401
|
+
const contexts = Object.entries(profileForEnvironment(config, environment).contexts ?? {});
|
|
6402
|
+
const exact = contexts.find(([key]) => key.toLowerCase() === normalized);
|
|
6403
|
+
if (exact) return exact[0];
|
|
6404
|
+
const matches = contexts.filter(([, context]) => [
|
|
6405
|
+
context.organizationId,
|
|
6406
|
+
context.organizationSlug,
|
|
6407
|
+
context.organizationName
|
|
6408
|
+
].some((value) => String(value || "").trim().toLowerCase() === normalized));
|
|
6409
|
+
if (matches.length > 1) {
|
|
6410
|
+
throw new Error(`Context "${selector.trim()}" is ambiguous in ${environment}; use an exact key from \`topolo --env ${environment} context ls\`.`);
|
|
6411
|
+
}
|
|
6412
|
+
return matches[0]?.[0] ?? null;
|
|
6413
|
+
}
|
|
6414
|
+
function commandContextKey(config, environment, explicit = process.env.TOPOLO_CONTEXT) {
|
|
6415
|
+
const selector = explicit?.trim();
|
|
6416
|
+
if (selector) return matchContextSelector(config, environment, selector) ?? selector;
|
|
6417
|
+
return implicitContextKey(config, environment) ?? void 0;
|
|
6418
|
+
}
|
|
6419
|
+
function implicitContextKey(config, environment) {
|
|
6420
|
+
if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
|
|
6421
|
+
if (profileForEnvironment(config, environment).credential?.kind === "api_key") return null;
|
|
6422
|
+
const keys = signedInContextKeys(config, environment);
|
|
6423
|
+
if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
|
|
6424
|
+
return keys[0] ?? null;
|
|
6425
|
+
}
|
|
6426
|
+
var ContextNotSafeToAssumeError = class extends Error {
|
|
6427
|
+
constructor(environment, contexts) {
|
|
6428
|
+
super(
|
|
6429
|
+
`This machine is signed in to ${contexts.length} Topolo contexts in ${environment} (${contexts.join(", ")}), so the context is no longer safe to assume.
|
|
6430
|
+
|
|
6431
|
+
State it explicitly:
|
|
6432
|
+
topolo --env ${environment} --context <${contexts.join("|")}> ...
|
|
6433
|
+
|
|
6434
|
+
Or set it once for the whole session:
|
|
6435
|
+
export TOPOLO_CONTEXT=<${contexts.join("|")}>`
|
|
6436
|
+
);
|
|
6437
|
+
this.environment = environment;
|
|
6438
|
+
this.contexts = contexts;
|
|
6439
|
+
this.name = "ContextNotSafeToAssumeError";
|
|
6440
|
+
}
|
|
6441
|
+
environment;
|
|
6442
|
+
contexts;
|
|
6443
|
+
};
|
|
6444
|
+
async function withPinnedContext(options, read = () => readConfig()) {
|
|
6445
|
+
if (options.context?.trim() || options.installationId?.trim()) return options;
|
|
6446
|
+
const environment = normalizeEnvironment(options.environment?.trim() || process.env.TOPOLO_ENV || "production");
|
|
6447
|
+
const keys = signedInContextKeys(await read(), environment);
|
|
6448
|
+
if (keys.length === 1) return { ...options, context: keys[0] };
|
|
6449
|
+
if (!keys.length) {
|
|
6450
|
+
throw new Error(
|
|
6451
|
+
`No Topolo context is signed in for ${environment}, so there is nothing to pin. Run \`topolo --env ${environment} auth login\` first, or pass --context <context>.`
|
|
6452
|
+
);
|
|
6453
|
+
}
|
|
6454
|
+
throw new Error(
|
|
6455
|
+
`${keys.length} Topolo contexts are signed in for ${environment} (${keys.join(", ")}). Pass --context <context> to choose the one this MCP registration pins.`
|
|
6456
|
+
);
|
|
6457
|
+
}
|
|
6458
|
+
|
|
5971
6459
|
// src/commands/auth.ts
|
|
5972
6460
|
function registerAuthCommands(program2) {
|
|
5973
6461
|
const auth = program2.command("auth").description("Manage Topolo credentials");
|
|
6462
|
+
registerAuthLockCommand(auth);
|
|
5974
6463
|
auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--no-browser", "Print the approval URL without opening a browser").option("--use", "Also make the approved context this environment's stored default (as `topolo context use`)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText("after", [
|
|
5975
6464
|
"",
|
|
5976
6465
|
"The approved sign-in is stored under its own context key (personal or org:<id>).",
|
|
@@ -6021,16 +6510,37 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
|
|
|
6021
6510
|
}
|
|
6022
6511
|
}
|
|
6023
6512
|
);
|
|
6024
|
-
auth.command("logout").description("Remove stored
|
|
6513
|
+
auth.command("logout").description("Remove every stored sign-in for this environment and revoke it at Topolo").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText("after", [
|
|
6514
|
+
"",
|
|
6515
|
+
"Removes the stored credential, every personal and organization context and any",
|
|
6516
|
+
"pending re-authorization for the selected environment, then revokes each removed",
|
|
6517
|
+
"OAuth sign-in at Topolo. Stored API keys are only removed from this machine.",
|
|
6518
|
+
"TOPOLO_* credential environment variables are not affected."
|
|
6519
|
+
].join("\n")).action(async (opts) => {
|
|
6025
6520
|
const mode = resolveOutputMode(opts.json);
|
|
6026
6521
|
const environment = selectedEnvironment();
|
|
6027
6522
|
try {
|
|
6028
|
-
const
|
|
6029
|
-
await
|
|
6523
|
+
const result = await signOut(environment);
|
|
6524
|
+
const source = credentialSourceFromConfig(await readConfig(), environment);
|
|
6525
|
+
const environmentCredential = source.startsWith("env:") ? source.slice(4) : null;
|
|
6526
|
+
if (result.revocationFailed > 0) process.exitCode = 1;
|
|
6030
6527
|
emit(
|
|
6031
|
-
{
|
|
6528
|
+
{
|
|
6529
|
+
ok: result.revocationFailed === 0,
|
|
6530
|
+
removedLocally: true,
|
|
6531
|
+
environment,
|
|
6532
|
+
removed: result.contexts,
|
|
6533
|
+
revoked: result.revoked,
|
|
6534
|
+
revocationFailed: result.revocationFailed,
|
|
6535
|
+
...environmentCredential ? { environmentCredential } : {}
|
|
6536
|
+
},
|
|
6032
6537
|
mode,
|
|
6033
|
-
() =>
|
|
6538
|
+
() => [
|
|
6539
|
+
result.contexts.length ? `Logged out of Topolo ${environmentLabel(environment)}: removed ${result.contexts.join(", ")}.` : `No stored sign-in for Topolo ${environmentLabel(environment)}.`,
|
|
6540
|
+
...result.revoked ? [`Revoked ${result.revoked} sign-in${result.revoked === 1 ? "" : "s"} at Topolo.`] : [],
|
|
6541
|
+
...result.revocationFailed ? [`Could not confirm revocation of ${result.revocationFailed} sign-in${result.revocationFailed === 1 ? "" : "s"} at Topolo; they are removed from this machine and expire on their own. Revoke them from your Topolo account to end them now.`] : [],
|
|
6542
|
+
...environmentCredential ? [`${environmentCredential} is still set, so commands keep using it until you unset it.`] : []
|
|
6543
|
+
].join("\n")
|
|
6034
6544
|
);
|
|
6035
6545
|
} catch (err) {
|
|
6036
6546
|
fail2(err, mode);
|
|
@@ -6066,13 +6576,23 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
|
|
|
6066
6576
|
async function storeApiKey(apiKey, environment, mode) {
|
|
6067
6577
|
if (!apiKey) throw new Error("API key is required");
|
|
6068
6578
|
const config = await readConfig();
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
);
|
|
6579
|
+
const next = writeProfileConfig(config, environment, {
|
|
6580
|
+
credential: { kind: "api_key", apiKey }
|
|
6581
|
+
});
|
|
6582
|
+
const profile = next.profiles?.[environment];
|
|
6583
|
+
if (profile) delete profile.activeContext;
|
|
6584
|
+
await writeConfig(next);
|
|
6585
|
+
const overriddenBy = [];
|
|
6586
|
+
const source = credentialSourceFromConfig(next, environment);
|
|
6587
|
+
if (source.startsWith("env:")) overriddenBy.push(source.slice(4));
|
|
6588
|
+
const pinned = process.env.TOPOLO_CONTEXT?.trim();
|
|
6589
|
+
if (pinned) overriddenBy.push(`TOPOLO_CONTEXT=${pinned}`);
|
|
6590
|
+
for (const name of overriddenBy) {
|
|
6591
|
+
process.stderr.write(`Warning: ${name} is set, so commands use it instead of the stored API key until you unset it.
|
|
6592
|
+
`);
|
|
6593
|
+
}
|
|
6074
6594
|
emit(
|
|
6075
|
-
{ ok: true, environment, credential: { kind: "api_key" } },
|
|
6595
|
+
{ ok: true, environment, credential: { kind: "api_key" }, ...overriddenBy.length ? { overriddenBy } : {} },
|
|
6076
6596
|
mode,
|
|
6077
6597
|
() => `Stored API key for Topolo ${environmentLabel(environment)}. Run \`topolo whoami\` to verify.`
|
|
6078
6598
|
);
|
|
@@ -6138,7 +6658,32 @@ async function runDeviceFlow(options) {
|
|
|
6138
6658
|
options.environment
|
|
6139
6659
|
);
|
|
6140
6660
|
const approvedKey = contextKey(context.contextType, context.organizationId);
|
|
6661
|
+
const approvedUser = identity?.user.id || accessTokenSubject(credential.accessToken);
|
|
6662
|
+
if (approvedUser) context.userId = approvedUser;
|
|
6141
6663
|
const updated = await readConfig();
|
|
6664
|
+
const storedUser = contextUser(updated, options.environment, approvedKey);
|
|
6665
|
+
if (storedUser && storedUser !== approvedUser) {
|
|
6666
|
+
const who2 = identity ? identity.user.email : "an identity that could not be verified";
|
|
6667
|
+
process.stderr.write(
|
|
6668
|
+
`
|
|
6669
|
+
\u26A0 The browser approved ${who2}, but ${approvedKey} on this machine belongs to a different user.
|
|
6670
|
+
Nothing was stored. Approve as that user, or run \`topolo --env ${options.environment} auth logout\` first to replace it.
|
|
6671
|
+
`
|
|
6672
|
+
);
|
|
6673
|
+
process.exitCode = 1;
|
|
6674
|
+
emit(
|
|
6675
|
+
{
|
|
6676
|
+
ok: false,
|
|
6677
|
+
environment: options.environment,
|
|
6678
|
+
error: "user_mismatch",
|
|
6679
|
+
approved: { contextKey: approvedKey, user: identity ? { email: identity.user.email, name: identity.user.name } : null },
|
|
6680
|
+
...options.requestedContext ? { requestedContext: options.requestedContext, requestedContextRenewed: false } : {}
|
|
6681
|
+
},
|
|
6682
|
+
options.mode,
|
|
6683
|
+
() => ` ${approvedKey} was not changed.`
|
|
6684
|
+
);
|
|
6685
|
+
return;
|
|
6686
|
+
}
|
|
6142
6687
|
const previousDefault = profileDefault(updated, options.environment);
|
|
6143
6688
|
const makeDefault = options.makeDefault === true || signedInContextKeys(updated, options.environment).length === 0;
|
|
6144
6689
|
await writeConfig(writeContextCredential(updated, options.environment, context, makeDefault));
|
|
@@ -6186,6 +6731,12 @@ async function runDeviceFlow(options) {
|
|
|
6186
6731
|
].join("\n")
|
|
6187
6732
|
);
|
|
6188
6733
|
}
|
|
6734
|
+
function contextUser(config, environment, key) {
|
|
6735
|
+
const profile = config.profiles?.[environment];
|
|
6736
|
+
const stored = profile?.contexts?.[key];
|
|
6737
|
+
const credential = stored?.credential;
|
|
6738
|
+
return stored?.userId ?? (credential?.kind === "access_token" ? accessTokenSubject(credential.accessToken) : void 0) ?? profile?.reauthorization?.[key]?.expectedSubject;
|
|
6739
|
+
}
|
|
6189
6740
|
function profileDefault(config, environment) {
|
|
6190
6741
|
return config.profiles?.[environment]?.activeContext;
|
|
6191
6742
|
}
|
|
@@ -6890,7 +7441,7 @@ function formatActionPlan(plan) {
|
|
|
6890
7441
|
}
|
|
6891
7442
|
|
|
6892
7443
|
// src/lib/credential-custody.ts
|
|
6893
|
-
import { createHash as
|
|
7444
|
+
import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
|
|
6894
7445
|
import { constants as constants2 } from "fs";
|
|
6895
7446
|
import { chmod as chmod3, lstat as lstat2, mkdir as mkdir3, open, rename as rename2, rm as rm2 } from "fs/promises";
|
|
6896
7447
|
import { basename as basename2, isAbsolute, join as join5, normalize, sep } from "path";
|
|
@@ -6966,7 +7517,7 @@ async function writeCredentialCustody(request, deps) {
|
|
|
6966
7517
|
environment: deps.environment,
|
|
6967
7518
|
apiKeyId: key.id,
|
|
6968
7519
|
expiresAt: key.expiresAt,
|
|
6969
|
-
keyDigest: `sha256:${
|
|
7520
|
+
keyDigest: `sha256:${createHash4("sha256").update(secret, "utf8").digest("hex")}`,
|
|
6970
7521
|
createdAt: key.createdAt
|
|
6971
7522
|
};
|
|
6972
7523
|
await writeCustodyFiles(directory, [
|
|
@@ -7722,7 +8273,7 @@ import { spawnSync as spawnSync5 } from "child_process";
|
|
|
7722
8273
|
import { existsSync as existsSync6 } from "fs";
|
|
7723
8274
|
import { readFile as readFile9, rm as rm3 } from "fs/promises";
|
|
7724
8275
|
import { homedir as homedir5 } from "os";
|
|
7725
|
-
import { dirname as dirname7, join as join11, resolve as
|
|
8276
|
+
import { dirname as dirname7, join as join11, resolve as resolve4 } from "path";
|
|
7726
8277
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
7727
8278
|
import { createRequire } from "module";
|
|
7728
8279
|
|
|
@@ -7737,7 +8288,7 @@ import { spawnSync as spawnSync3 } from "child_process";
|
|
|
7737
8288
|
import { existsSync as existsSync4 } from "fs";
|
|
7738
8289
|
import { readFile as readFile4 } from "fs/promises";
|
|
7739
8290
|
import { homedir as homedir4 } from "os";
|
|
7740
|
-
import { isAbsolute as isAbsolute2, join as join7, resolve as
|
|
8291
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve3 } from "path";
|
|
7741
8292
|
var SUPPORTED_AGENT_IDS = [
|
|
7742
8293
|
"claude",
|
|
7743
8294
|
"claude-desktop",
|
|
@@ -7778,14 +8329,14 @@ var AGENT_NAME_FOR = {
|
|
|
7778
8329
|
"amazon-q": "amazon-q"
|
|
7779
8330
|
};
|
|
7780
8331
|
function resolveAgentHostTarget(options) {
|
|
7781
|
-
const homeDir =
|
|
8332
|
+
const homeDir = resolve3(options.homeDir ?? homedir4());
|
|
7782
8333
|
const codexHome = options.codexHome ?? (options.homeDir === void 0 ? process.env.CODEX_HOME || void 0 : void 0);
|
|
7783
8334
|
if (codexHome !== void 0 && (!codexHome.trim() || !isAbsolute2(codexHome))) {
|
|
7784
8335
|
throw new Error("Codex home must be an absolute nonempty directory.");
|
|
7785
8336
|
}
|
|
7786
8337
|
return Object.freeze({
|
|
7787
8338
|
homeDir,
|
|
7788
|
-
...options.projectDir ? { projectDir:
|
|
8339
|
+
...options.projectDir ? { projectDir: resolve3(options.projectDir) } : {},
|
|
7789
8340
|
codexHome: codexHome ?? join7(homeDir, ".codex")
|
|
7790
8341
|
});
|
|
7791
8342
|
}
|
|
@@ -7947,7 +8498,7 @@ async function assertClaudeProjectScope(homeDir, projectDir, name) {
|
|
|
7947
8498
|
}
|
|
7948
8499
|
return value;
|
|
7949
8500
|
};
|
|
7950
|
-
const local = object(object(config.projects)?.[
|
|
8501
|
+
const local = object(object(config.projects)?.[resolve3(projectDir)]);
|
|
7951
8502
|
if (Object.hasOwn(object(config.mcpServers) ?? {}, name) || Object.hasOwn(object(local?.mcpServers) ?? {}, name)) {
|
|
7952
8503
|
throw new Error("Claude MCP scope conflict for " + name + ": a user or local definition can override the project registration. Resolve that scope before setup.");
|
|
7953
8504
|
}
|
|
@@ -9040,8 +9591,8 @@ function formatSharedSetup(result) {
|
|
|
9040
9591
|
var AGENT_LABELS2 = AGENT_LABELS;
|
|
9041
9592
|
function skillsSourceDir() {
|
|
9042
9593
|
const here = dirname7(fileURLToPath3(import.meta.url));
|
|
9043
|
-
const distCandidate =
|
|
9044
|
-
const srcCandidate =
|
|
9594
|
+
const distCandidate = resolve4(here, "../skills");
|
|
9595
|
+
const srcCandidate = resolve4(here, "../../skills");
|
|
9045
9596
|
for (const candidate of [distCandidate, srcCandidate]) {
|
|
9046
9597
|
if (existsSync6(join11(candidate, "topolo", "SKILL.md"))) return candidate;
|
|
9047
9598
|
}
|
|
@@ -9168,7 +9719,7 @@ function registerSkillsCommand(program2) {
|
|
|
9168
9719
|
const mode = resolveOutputMode(opts.json);
|
|
9169
9720
|
try {
|
|
9170
9721
|
const rows = await agentSurfaceStatus({
|
|
9171
|
-
homeDir: opts.agentHome ?
|
|
9722
|
+
homeDir: opts.agentHome ? resolve4(opts.agentHome) : void 0,
|
|
9172
9723
|
codexHome: opts.codexHome,
|
|
9173
9724
|
name: opts.name,
|
|
9174
9725
|
agent: opts.agent,
|
|
@@ -9185,7 +9736,7 @@ function registerSkillsCommand(program2) {
|
|
|
9185
9736
|
try {
|
|
9186
9737
|
const removed = await uninstallAgentSurfaces({
|
|
9187
9738
|
...opts,
|
|
9188
|
-
homeDir: opts.agentHome ?
|
|
9739
|
+
homeDir: opts.agentHome ? resolve4(opts.agentHome) : void 0
|
|
9189
9740
|
});
|
|
9190
9741
|
emit(
|
|
9191
9742
|
{ removed },
|
|
@@ -9328,7 +9879,7 @@ function formatStatus(rows) {
|
|
|
9328
9879
|
// src/commands/doctor.ts
|
|
9329
9880
|
import { access as access2 } from "fs/promises";
|
|
9330
9881
|
import { homedir as homedir6 } from "os";
|
|
9331
|
-
import { resolve as
|
|
9882
|
+
import { resolve as resolve5 } from "path";
|
|
9332
9883
|
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9333
9884
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9334
9885
|
|
|
@@ -9504,7 +10055,7 @@ async function verifyMcpProtocol(command, args, env = definedEnvironment(process
|
|
|
9504
10055
|
}
|
|
9505
10056
|
async function resolveInstalledDoctorTarget(options) {
|
|
9506
10057
|
const name = options.name?.trim() || "topolo";
|
|
9507
|
-
const homeDir = options.agentHome ?
|
|
10058
|
+
const homeDir = options.agentHome ? resolve5(options.agentHome) : homedir6();
|
|
9508
10059
|
const expected = resolveMcpCommand();
|
|
9509
10060
|
const requestedAgents = resolveTargets({
|
|
9510
10061
|
...options,
|
|
@@ -9610,12 +10161,12 @@ function credentialFromTokens(tokens) {
|
|
|
9610
10161
|
const accessToken = tokens.accessToken || tokens.access_token;
|
|
9611
10162
|
const refreshToken = tokens.refreshToken || tokens.refresh_token;
|
|
9612
10163
|
const expiresIn = tokens.expiresIn || tokens.expires_in;
|
|
9613
|
-
const
|
|
10164
|
+
const expiresAt = (tokens.expiresAt || tokens.expires_at) ?? (expiresIn ? Math.floor(Date.now() / 1e3) + expiresIn : accessTokenExpiry(accessToken));
|
|
9614
10165
|
return {
|
|
9615
10166
|
kind: "access_token",
|
|
9616
10167
|
accessToken,
|
|
9617
10168
|
...refreshToken ? { refreshToken } : {},
|
|
9618
|
-
...
|
|
10169
|
+
...expiresAt ? { expiresAt } : {}
|
|
9619
10170
|
};
|
|
9620
10171
|
}
|
|
9621
10172
|
async function exchangeForContext(credential, serviceUrls, target) {
|
|
@@ -9845,6 +10396,8 @@ function registerContextCommand(program2) {
|
|
|
9845
10396
|
}
|
|
9846
10397
|
resolved.credential = newCredential;
|
|
9847
10398
|
resolved.oauthClientId = existing?.oauthClientId ?? profile.oauthClientId;
|
|
10399
|
+
const userId = accessTokenSubject(newCredential.accessToken);
|
|
10400
|
+
if (userId) resolved.userId = userId;
|
|
9848
10401
|
const updated = writeContextCredential(await readConfig(), environment, resolved, true);
|
|
9849
10402
|
await writeConfig(updated);
|
|
9850
10403
|
emit(
|
|
@@ -10155,7 +10708,7 @@ function registerInstallationsCommand(program2) {
|
|
|
10155
10708
|
import { createTopoloMcpLocalHandler } from "@topolo/mcp/local";
|
|
10156
10709
|
|
|
10157
10710
|
// src/lib/mcp-hub-server.ts
|
|
10158
|
-
import { createHash as
|
|
10711
|
+
import { createHash as createHash5, timingSafeEqual } from "crypto";
|
|
10159
10712
|
import { stat as stat2 } from "fs/promises";
|
|
10160
10713
|
import { createServer } from "http";
|
|
10161
10714
|
var MCP_HUB_HOST = "127.0.0.1";
|
|
@@ -10170,7 +10723,7 @@ function refusal(status, message3, headers = {}) {
|
|
|
10170
10723
|
return json(status, { jsonrpc: "2.0", error: { code: -32e3, message: message3 }, id: null }, headers);
|
|
10171
10724
|
}
|
|
10172
10725
|
function digest(value) {
|
|
10173
|
-
return
|
|
10726
|
+
return createHash5("sha256").update(value, "utf8").digest();
|
|
10174
10727
|
}
|
|
10175
10728
|
function bearerTokenMatches(header, token) {
|
|
10176
10729
|
const match = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(header ?? "");
|
|
@@ -10301,11 +10854,11 @@ async function writeWebResponse(response, target) {
|
|
|
10301
10854
|
}
|
|
10302
10855
|
async function startMcpHub(options) {
|
|
10303
10856
|
const server = createServer();
|
|
10304
|
-
await new Promise((
|
|
10857
|
+
await new Promise((resolve6, reject) => {
|
|
10305
10858
|
server.once("error", reject);
|
|
10306
10859
|
server.listen(options.port, MCP_HUB_HOST, () => {
|
|
10307
10860
|
server.off("error", reject);
|
|
10308
|
-
|
|
10861
|
+
resolve6();
|
|
10309
10862
|
});
|
|
10310
10863
|
});
|
|
10311
10864
|
const port = server.address().port;
|
|
@@ -10336,8 +10889,8 @@ async function startMcpHub(options) {
|
|
|
10336
10889
|
return {
|
|
10337
10890
|
port,
|
|
10338
10891
|
server,
|
|
10339
|
-
close: () => new Promise((
|
|
10340
|
-
server.close(() =>
|
|
10892
|
+
close: () => new Promise((resolve6) => {
|
|
10893
|
+
server.close(() => resolve6());
|
|
10341
10894
|
server.closeAllConnections();
|
|
10342
10895
|
})
|
|
10343
10896
|
};
|