@oxygen-agent/cli 1.334.3 → 1.336.4
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 +1 -1
- package/dist/browser-login.d.ts +16 -0
- package/dist/browser-login.js +131 -1
- package/dist/index.js +100 -15
- package/node_modules/@oxygen/shared/dist/cli-login-code.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/cli-login-code.js +30 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/browser-login.d.ts
CHANGED
|
@@ -3,5 +3,21 @@ export type BrowserLoginSession = {
|
|
|
3
3
|
waitForToken: () => Promise<string>;
|
|
4
4
|
close: () => Promise<void>;
|
|
5
5
|
};
|
|
6
|
+
export type RelayLoginSession = BrowserLoginSession & {
|
|
7
|
+
confirmationCode: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Relay browser login (device-flow style): register a login request
|
|
11
|
+
* server-side, send the user to /settings/cli to approve it, and poll the
|
|
12
|
+
* claim endpoint until the session token is released. No loopback server is
|
|
13
|
+
* involved, so it works where the browser→localhost POST cannot: browsers
|
|
14
|
+
* enforcing the Local Network Access permission, SSH/WSL/remote terminals,
|
|
15
|
+
* containers. Any failure to CREATE the request (old server without the
|
|
16
|
+
* endpoint, network policy) is the caller's cue to fall back to the legacy
|
|
17
|
+
* loopback flow.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createRelayLoginSession(apiUrl: string, options?: {
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}): Promise<RelayLoginSession>;
|
|
6
22
|
export declare function createBrowserLoginSession(apiUrl: string): Promise<BrowserLoginSession>;
|
|
7
23
|
export declare function openBrowser(url: string): boolean;
|
package/dist/browser-login.js
CHANGED
|
@@ -1,11 +1,141 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import {
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { OXYGEN_VERSION, OxygenError, deriveCliLoginConfirmationCode } from "@oxygen/shared";
|
|
6
|
+
import { requestOxygen } from "./http-client.js";
|
|
5
7
|
import { readErrorMessage } from "./util.js";
|
|
6
8
|
const CALLBACK_HOST = "127.0.0.1";
|
|
7
9
|
const CALLBACK_PATH = "/cli/login/callback";
|
|
8
10
|
const MAX_CALLBACK_BODY_BYTES = 32_000;
|
|
11
|
+
const RELAY_CREATE_TIMEOUT_MS = 10_000;
|
|
12
|
+
const RELAY_CLAIM_TIMEOUT_MS = 15_000;
|
|
13
|
+
const DEFAULT_RELAY_POLL_INTERVAL_SECONDS = 2;
|
|
14
|
+
const MAX_RELAY_POLL_INTERVAL_SECONDS = 10;
|
|
15
|
+
const MAX_CONSECUTIVE_TRANSIENT_CLAIM_ERRORS = 5;
|
|
16
|
+
// Claim-poll errors that read as infrastructure hiccups rather than a verdict
|
|
17
|
+
// about the login request itself — tolerated a few times before giving up.
|
|
18
|
+
const TRANSIENT_CLAIM_ERROR_CODES = new Set([
|
|
19
|
+
"network_error",
|
|
20
|
+
"network_timeout",
|
|
21
|
+
"invalid_response",
|
|
22
|
+
"unexpected_error",
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Relay browser login (device-flow style): register a login request
|
|
26
|
+
* server-side, send the user to /settings/cli to approve it, and poll the
|
|
27
|
+
* claim endpoint until the session token is released. No loopback server is
|
|
28
|
+
* involved, so it works where the browser→localhost POST cannot: browsers
|
|
29
|
+
* enforcing the Local Network Access permission, SSH/WSL/remote terminals,
|
|
30
|
+
* containers. Any failure to CREATE the request (old server without the
|
|
31
|
+
* endpoint, network policy) is the caller's cue to fall back to the legacy
|
|
32
|
+
* loopback flow.
|
|
33
|
+
*/
|
|
34
|
+
export async function createRelayLoginSession(apiUrl, options = {}) {
|
|
35
|
+
const state = randomBytes(32).toString("base64url");
|
|
36
|
+
// Empty token: pins the host to --api-url/profile resolution while sending no
|
|
37
|
+
// Authorization header — these endpoints are pre-auth by design.
|
|
38
|
+
const credentials = { token: "", apiUrl };
|
|
39
|
+
const requestOptions = options.fetch ? { fetch: options.fetch } : {};
|
|
40
|
+
const created = await requestOxygen("/api/cli/login-requests", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
credentials,
|
|
43
|
+
body: {
|
|
44
|
+
state,
|
|
45
|
+
client: {
|
|
46
|
+
os: process.platform,
|
|
47
|
+
hostname: safeHostname(),
|
|
48
|
+
cli_version: OXYGEN_VERSION,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
timeoutMs: RELAY_CREATE_TIMEOUT_MS,
|
|
52
|
+
...requestOptions,
|
|
53
|
+
});
|
|
54
|
+
const pollIntervalMs = normalizePollIntervalSeconds(created.interval_seconds) * 1000;
|
|
55
|
+
const redirectParams = new URLSearchParams({
|
|
56
|
+
cli_state: state,
|
|
57
|
+
cli_login_request: "1",
|
|
58
|
+
source: "oxygen_cli",
|
|
59
|
+
});
|
|
60
|
+
const verificationUrl = new URL("/auth/sign-in", apiUrl);
|
|
61
|
+
verificationUrl.searchParams.set("redirect_url", `/settings/cli?${redirectParams.toString()}`);
|
|
62
|
+
verificationUrl.searchParams.set("source", "oxygen_cli");
|
|
63
|
+
let closed = false;
|
|
64
|
+
async function waitForToken() {
|
|
65
|
+
let transientErrors = 0;
|
|
66
|
+
while (!closed) {
|
|
67
|
+
await sleep(pollIntervalMs);
|
|
68
|
+
if (closed)
|
|
69
|
+
break;
|
|
70
|
+
let claim;
|
|
71
|
+
try {
|
|
72
|
+
claim = await requestOxygen("/api/cli/login-requests/claim", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
credentials,
|
|
75
|
+
body: { state },
|
|
76
|
+
timeoutMs: RELAY_CLAIM_TIMEOUT_MS,
|
|
77
|
+
...requestOptions,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof OxygenError && error.code === "rate_limit_exceeded") {
|
|
82
|
+
await sleep(Math.max(readRetryAfterMs(error), pollIntervalMs));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (error instanceof OxygenError && TRANSIENT_CLAIM_ERROR_CODES.has(error.code)) {
|
|
86
|
+
transientErrors += 1;
|
|
87
|
+
if (transientErrors >= MAX_CONSECUTIVE_TRANSIENT_CLAIM_ERRORS)
|
|
88
|
+
throw error;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Typed verdicts (login_request_expired / _not_found / _already_claimed)
|
|
92
|
+
// and anything else surface immediately.
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
transientErrors = 0;
|
|
96
|
+
if (claim.status === "ready" && typeof claim.token === "string" && claim.token) {
|
|
97
|
+
return claim.token;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new OxygenError("browser_login_closed", "Browser login closed.", { exitCode: 1 });
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
verificationUrl: verificationUrl.toString(),
|
|
104
|
+
confirmationCode: deriveCliLoginConfirmationCode(state),
|
|
105
|
+
waitForToken,
|
|
106
|
+
close: () => {
|
|
107
|
+
closed = true;
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function normalizePollIntervalSeconds(value) {
|
|
113
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
114
|
+
return DEFAULT_RELAY_POLL_INTERVAL_SECONDS;
|
|
115
|
+
}
|
|
116
|
+
return Math.min(MAX_RELAY_POLL_INTERVAL_SECONDS, Math.max(1, Math.trunc(value)));
|
|
117
|
+
}
|
|
118
|
+
function readRetryAfterMs(error) {
|
|
119
|
+
const details = error.details;
|
|
120
|
+
if (details && typeof details === "object" && !Array.isArray(details)) {
|
|
121
|
+
const seconds = details.retry_after_seconds;
|
|
122
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) {
|
|
123
|
+
return Math.min(seconds, 60) * 1000;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return DEFAULT_RELAY_POLL_INTERVAL_SECONDS * 1000;
|
|
127
|
+
}
|
|
128
|
+
function safeHostname() {
|
|
129
|
+
try {
|
|
130
|
+
return hostname();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function sleep(ms) {
|
|
137
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
138
|
+
}
|
|
9
139
|
export async function createBrowserLoginSession(apiUrl) {
|
|
10
140
|
const state = randomBytes(32).toString("base64url");
|
|
11
141
|
let settled = false;
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, describeWorkflowSt
|
|
|
13
13
|
import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
|
|
14
14
|
import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
|
|
15
15
|
import { isRecipeDefinition } from "@oxygen/recipe-sdk";
|
|
16
|
-
import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
|
|
16
|
+
import { createBrowserLoginSession, createRelayLoginSession, openBrowser } from "./browser-login.js";
|
|
17
17
|
import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
|
|
18
18
|
import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
|
|
19
19
|
import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, listLocalMirrors, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
|
|
@@ -63,6 +63,7 @@ function buildFindBody(capability, options) {
|
|
|
63
63
|
return body;
|
|
64
64
|
}
|
|
65
65
|
const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
66
|
+
const BROWSER_LOGIN_FALLBACK_HINT_MS = 15 * 1000;
|
|
66
67
|
const OXYGEN_SPINNER_INTERVAL_MS = 90;
|
|
67
68
|
const INITIAL_OXYGEN_PROFILE_ENV = process.env.OXYGEN_PROFILE?.trim() || null;
|
|
68
69
|
let globalProfileFlag = null;
|
|
@@ -1595,7 +1596,7 @@ export function createProgram() {
|
|
|
1595
1596
|
});
|
|
1596
1597
|
program
|
|
1597
1598
|
.command("login")
|
|
1598
|
-
.description("Connect this terminal to Oxygen.")
|
|
1599
|
+
.description("Connect this terminal to Oxygen. Opens the browser to approve this terminal (matching confirmation code shown in both); paste a token with --token instead if you prefer.")
|
|
1599
1600
|
.option("--token <token>", "CLI API token created in the Oxygen dashboard.")
|
|
1600
1601
|
.option("--api-url <url>", "Oxygen API URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
|
|
1601
1602
|
.option("--profile <name>", "Store credentials under a named CLI profile and make it active.")
|
|
@@ -1607,7 +1608,7 @@ export function createProgram() {
|
|
|
1607
1608
|
});
|
|
1608
1609
|
program
|
|
1609
1610
|
.command("auth")
|
|
1610
|
-
.description("Authentication helpers.")
|
|
1611
|
+
.description("Authentication helpers for scripts and agents (non-interactive token use). Day to day, `oxygen login` and `oxygen whoami` are all you need.")
|
|
1611
1612
|
.addCommand(new Command("use-token")
|
|
1612
1613
|
.description("Store a CLI API token non-interactively.")
|
|
1613
1614
|
.requiredOption("--token <token>", "CLI API token.")
|
|
@@ -1626,7 +1627,7 @@ export function createProgram() {
|
|
|
1626
1627
|
}));
|
|
1627
1628
|
program
|
|
1628
1629
|
.command("profiles")
|
|
1629
|
-
.description("Manage stored CLI credential profiles.")
|
|
1630
|
+
.description("Manage stored CLI credential profiles (switch between workspaces and hosts). `oxygen login` creates or updates the active profile.")
|
|
1630
1631
|
.addCommand(new Command("list")
|
|
1631
1632
|
.description("List stored CLI credential profiles.")
|
|
1632
1633
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3620,7 +3621,57 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3620
3621
|
});
|
|
3621
3622
|
});
|
|
3622
3623
|
}));
|
|
3623
|
-
tablesCommand.addCommand(new Command("
|
|
3624
|
+
tablesCommand.addCommand(new Command("relate")
|
|
3625
|
+
.description("Relate two tables: define a relation column on the source that links rows to the target table. Works on ANY table — plain tables are registered under the hood automatically. Defaults to dry-run.")
|
|
3626
|
+
.argument("<table>", "Source table id or slug.")
|
|
3627
|
+
.argument("<slug>", "Relation slug — also the relation column key on the source, such as client.")
|
|
3628
|
+
.requiredOption("--target-table <table>", "Target table id or slug. May also be a CRM object table (companies, people, deals).")
|
|
3629
|
+
.option("--display-name <name>", "Display name for the relation column. Defaults to the slug in Title Case.")
|
|
3630
|
+
.option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many (source:target). Default many_to_many. one_to_many means each source row links many target rows and each target row links back to one source.")
|
|
3631
|
+
.option("--inverse-slug <slug>", "Inverse relation slug on the target; also creates the inverse relation column there.")
|
|
3632
|
+
.option("--inverse-display-name <name>", "Display name for the inverse side.")
|
|
3633
|
+
.option("--dry-run", "Preview the relation plan without writing.")
|
|
3634
|
+
.option("--live", "Apply the relation. Default is dry-run.")
|
|
3635
|
+
.option("--json", "Print a JSON envelope.")
|
|
3636
|
+
.action(async (table, slug, options) => {
|
|
3637
|
+
await handleAsyncAction("tables relate", options, () => requestOxygen("/api/cli/tables/relations", {
|
|
3638
|
+
method: "POST",
|
|
3639
|
+
body: {
|
|
3640
|
+
table,
|
|
3641
|
+
relationship: {
|
|
3642
|
+
slug,
|
|
3643
|
+
target_table: options.targetTable,
|
|
3644
|
+
...(options.displayName ? { display_name: options.displayName } : {}),
|
|
3645
|
+
...(options.cardinality ? { cardinality: options.cardinality } : {}),
|
|
3646
|
+
...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
|
|
3647
|
+
...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
|
|
3648
|
+
},
|
|
3649
|
+
mode: options.live ? "live" : "dry_run",
|
|
3650
|
+
},
|
|
3651
|
+
}));
|
|
3652
|
+
}))
|
|
3653
|
+
.addCommand(new Command("link")
|
|
3654
|
+
.description("Link one row to a row in the related table through a relation defined with `tables relate`. Defaults to dry-run.")
|
|
3655
|
+
.argument("<table>", "Source table id or slug.")
|
|
3656
|
+
.argument("<row_id>", "Source row id.")
|
|
3657
|
+
.requiredOption("--relation <slug>", "Relation slug defined on the source table, such as client.")
|
|
3658
|
+
.requiredOption("--target-row-id <row_id>", "Target row id in the related table.")
|
|
3659
|
+
.option("--dry-run", "Preview the link (and any cardinality replacements) without writing.")
|
|
3660
|
+
.option("--live", "Apply the link. Default is dry-run.")
|
|
3661
|
+
.option("--json", "Print a JSON envelope.")
|
|
3662
|
+
.action(async (table, rowId, options) => {
|
|
3663
|
+
await handleAsyncAction("tables link", options, () => requestOxygen("/api/cli/tables/relations/link", {
|
|
3664
|
+
method: "POST",
|
|
3665
|
+
body: {
|
|
3666
|
+
table,
|
|
3667
|
+
row_id: rowId,
|
|
3668
|
+
relation: options.relation,
|
|
3669
|
+
target_row_id: options.targetRowId,
|
|
3670
|
+
mode: options.live ? "live" : "dry_run",
|
|
3671
|
+
},
|
|
3672
|
+
}));
|
|
3673
|
+
}))
|
|
3674
|
+
.addCommand(new Command("promote")
|
|
3624
3675
|
.description("Write bound rows' columns onto their matched CRM records (Records<->Tables). Free, but a truth write: previews by default, then re-run with --approved to write. Approved runs queue as a background run — watch with `oxygen table-runs wait <run_id>`.")
|
|
3625
3676
|
.argument("<table>", "Table id or slug whose bound rows to promote.")
|
|
3626
3677
|
.option("--object <slug>", "CRM object to promote onto (companies, people, deals, ...). Inferred when the table has exactly one bind column.")
|
|
@@ -13874,24 +13925,20 @@ async function buildPostLoginHint(activeProfile) {
|
|
|
13874
13925
|
}
|
|
13875
13926
|
async function promptForToken(options) {
|
|
13876
13927
|
const fallbackLoginUrl = createCliLoginUrl(options.apiUrl);
|
|
13928
|
+
const settingsUrl = cliSettingsUrl(options.apiUrl);
|
|
13877
13929
|
if (options.json) {
|
|
13878
|
-
throw new OxygenError("missing_token",
|
|
13930
|
+
throw new OxygenError("missing_token", `Pass a CLI API token with --token when using --json. Create one at ${settingsUrl}.`, {
|
|
13879
13931
|
details: { login_url: fallbackLoginUrl },
|
|
13880
13932
|
exitCode: 1,
|
|
13881
13933
|
});
|
|
13882
13934
|
}
|
|
13883
13935
|
if (!process.stdin.isTTY) {
|
|
13884
|
-
throw new OxygenError("missing_token",
|
|
13936
|
+
throw new OxygenError("missing_token", `Pass a CLI API token with --token, or run \`oxygen login\` in an interactive terminal. Create a token at ${settingsUrl}.`, {
|
|
13885
13937
|
details: { login_url: fallbackLoginUrl },
|
|
13886
13938
|
exitCode: 1,
|
|
13887
13939
|
});
|
|
13888
13940
|
}
|
|
13889
|
-
const browserSession = options.browser
|
|
13890
|
-
? await createBrowserLoginSession(options.apiUrl).catch((error) => {
|
|
13891
|
-
output.write(`Browser login unavailable: ${readErrorMessage(error)}\n`);
|
|
13892
|
-
return null;
|
|
13893
|
-
})
|
|
13894
|
-
: null;
|
|
13941
|
+
const browserSession = options.browser ? await createLoginSession(options.apiUrl) : null;
|
|
13895
13942
|
if (browserSession) {
|
|
13896
13943
|
const opened = openBrowser(browserSession.verificationUrl);
|
|
13897
13944
|
if (opened) {
|
|
@@ -13901,7 +13948,19 @@ async function promptForToken(options) {
|
|
|
13901
13948
|
output.write("Open this URL in your browser:\n");
|
|
13902
13949
|
output.write(`${browserSession.verificationUrl}\n`);
|
|
13903
13950
|
}
|
|
13904
|
-
|
|
13951
|
+
if ("confirmationCode" in browserSession) {
|
|
13952
|
+
output.write(`Confirmation code: ${browserSession.confirmationCode}\n`);
|
|
13953
|
+
output.write("Approve the login in your browser — it must show this exact code.\n");
|
|
13954
|
+
}
|
|
13955
|
+
let spinner = startOxygenSpinner("Waiting for browser authorization");
|
|
13956
|
+
// One-shot fallback hint: after 15s of silence, tell the user how to finish
|
|
13957
|
+
// manually (blocked browsers and remote terminals used to look like a hang).
|
|
13958
|
+
const hintTimer = setTimeout(() => {
|
|
13959
|
+
spinner.stop();
|
|
13960
|
+
output.write(`Still waiting? To finish manually: open ${settingsUrl}, create a session, and paste the \`oxygen login --token <token>\` command it shows.\n`);
|
|
13961
|
+
spinner = startOxygenSpinner("Waiting for browser authorization");
|
|
13962
|
+
}, BROWSER_LOGIN_FALLBACK_HINT_MS);
|
|
13963
|
+
hintTimer.unref?.();
|
|
13905
13964
|
try {
|
|
13906
13965
|
const token = (await waitForBrowserToken(browserSession)).trim();
|
|
13907
13966
|
if (token) {
|
|
@@ -13911,15 +13970,38 @@ async function promptForToken(options) {
|
|
|
13911
13970
|
}
|
|
13912
13971
|
catch (error) {
|
|
13913
13972
|
spinner.fail(`Browser login unavailable: ${readErrorMessage(error)}`);
|
|
13914
|
-
output.write(
|
|
13973
|
+
output.write(`Paste a CLI API token instead (create one at ${settingsUrl}).\n`);
|
|
13915
13974
|
}
|
|
13916
13975
|
finally {
|
|
13976
|
+
clearTimeout(hintTimer);
|
|
13917
13977
|
spinner.stop();
|
|
13918
13978
|
await browserSession.close();
|
|
13919
13979
|
}
|
|
13920
13980
|
}
|
|
13921
13981
|
return promptForManualToken();
|
|
13922
13982
|
}
|
|
13983
|
+
/**
|
|
13984
|
+
* Relay login first (server-side approve + claim — works in every browser and
|
|
13985
|
+
* for remote/SSH terminals); legacy loopback only when the relay request cannot
|
|
13986
|
+
* even be created (older server without the endpoint, network policy). Any
|
|
13987
|
+
* create failure falls back — an old server answers with an HTML 404 that
|
|
13988
|
+
* surfaces as invalid_response, not not_found.
|
|
13989
|
+
*/
|
|
13990
|
+
async function createLoginSession(apiUrl) {
|
|
13991
|
+
try {
|
|
13992
|
+
return await createRelayLoginSession(apiUrl);
|
|
13993
|
+
}
|
|
13994
|
+
catch {
|
|
13995
|
+
// Fall through to the legacy loopback handoff.
|
|
13996
|
+
}
|
|
13997
|
+
try {
|
|
13998
|
+
return await createBrowserLoginSession(apiUrl);
|
|
13999
|
+
}
|
|
14000
|
+
catch (error) {
|
|
14001
|
+
output.write(`Browser login unavailable: ${readErrorMessage(error)}\n`);
|
|
14002
|
+
return null;
|
|
14003
|
+
}
|
|
14004
|
+
}
|
|
13923
14005
|
async function promptForManualToken() {
|
|
13924
14006
|
const restoreEcho = muteTokenEcho();
|
|
13925
14007
|
const rl = createInterface({ input, output });
|
|
@@ -13961,6 +14043,9 @@ function createCliLoginUrl(apiUrl) {
|
|
|
13961
14043
|
url.searchParams.set("source", "oxygen_cli");
|
|
13962
14044
|
return url.toString();
|
|
13963
14045
|
}
|
|
14046
|
+
function cliSettingsUrl(apiUrl) {
|
|
14047
|
+
return new URL("/settings/cli", apiUrl).toString();
|
|
14048
|
+
}
|
|
13964
14049
|
function startOxygenSpinner(message) {
|
|
13965
14050
|
if (!output.isTTY) {
|
|
13966
14051
|
output.write(`${message}...\n`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Confirmation-code derivation for the CLI relay browser login ("login request")
|
|
4
|
+
* flow. The CLI generates a 32-byte base64url `state`, registers its hash with
|
|
5
|
+
* POST /api/cli/login-requests, and polls the claim endpoint; the signed-in
|
|
6
|
+
* /settings/cli page approves the request after the user compares the code it
|
|
7
|
+
* shows with the code the CLI printed.
|
|
8
|
+
*
|
|
9
|
+
* The code is a human-comparable fingerprint of `state` — a guard against
|
|
10
|
+
* approving the wrong terminal (two logins racing, a stale tab), NOT a security
|
|
11
|
+
* boundary: approval authority is the signed-in click, and secrecy lives in the
|
|
12
|
+
* 256-bit state itself, which is stored hash-only server-side. Both consumers
|
|
13
|
+
* are Node (CLI prints it; the API derives it at create time and returns it to
|
|
14
|
+
* the panel via lookup), so this stays a plain node:crypto helper — the browser
|
|
15
|
+
* never computes it.
|
|
16
|
+
*/
|
|
17
|
+
const CLI_LOGIN_CODE_DOMAIN = "oxygen_cli_login_code_v1:";
|
|
18
|
+
/** 32 random bytes, base64url — always 43 chars of [A-Za-z0-9_-]. */
|
|
19
|
+
const CLI_LOGIN_STATE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
export function isValidCliLoginState(value) {
|
|
21
|
+
return CLI_LOGIN_STATE_PATTERN.test(value);
|
|
22
|
+
}
|
|
23
|
+
/** Uppercase first 8 hex chars of sha256(domain + state), formatted XXXX-XXXX. */
|
|
24
|
+
export function deriveCliLoginConfirmationCode(state) {
|
|
25
|
+
const digest = createHash("sha256")
|
|
26
|
+
.update(`${CLI_LOGIN_CODE_DOMAIN}${state}`, "utf8")
|
|
27
|
+
.digest("hex");
|
|
28
|
+
const compact = digest.slice(0, 8).toUpperCase();
|
|
29
|
+
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
|
|
30
|
+
}
|
|
@@ -5,6 +5,7 @@ export * from "./billing.js";
|
|
|
5
5
|
export * from "./budget-scopes.js";
|
|
6
6
|
export * from "./cell-format.js";
|
|
7
7
|
export * from "./cli-envelope.js";
|
|
8
|
+
export * from "./cli-login-code.js";
|
|
8
9
|
export * from "./cli-result.js";
|
|
9
10
|
export * from "./column-types.js";
|
|
10
11
|
export * from "./credit-guidance.js";
|
|
@@ -5,6 +5,7 @@ export * from "./billing.js";
|
|
|
5
5
|
export * from "./budget-scopes.js";
|
|
6
6
|
export * from "./cell-format.js";
|
|
7
7
|
export * from "./cli-envelope.js";
|
|
8
|
+
export * from "./cli-login-code.js";
|
|
8
9
|
export * from "./cli-result.js";
|
|
9
10
|
export * from "./column-types.js";
|
|
10
11
|
export * from "./credit-guidance.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.336.4";
|
|
2
2
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
3
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
4
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|