@giovannijecha/jecode 0.1.9 → 0.2.0
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 +32 -15
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +1 -1
- package/dist/controller.js +3 -0
- package/dist/credential-commands.js +32 -4
- package/dist/credential-safety.js +2 -1
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +91 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/provider-commands.js +18 -30
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +114 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -4
- package/dist/providers/openai.js +2 -2
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +11 -3
- package/dist/tui/app-workflows.js +5 -0
- package/dist/tui/feedback.js +10 -6
- package/dist/tui/session-view.js +10 -4
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
|
-
> Jecode is an early 0.
|
|
28
|
+
> Jecode is an early 0.2.x release. The core loop is usable today; commands and
|
|
29
29
|
> terminal interactions may still evolve before 1.0.
|
|
30
30
|
|
|
31
31
|
## Why Jecode
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
diffs, approvals, reasoning, and status all share one full-screen TUI.
|
|
37
37
|
- **Permission-aware.** Reads stay transparent; dangerous actions ask first.
|
|
38
38
|
Session approvals can be reviewed and revoked.
|
|
39
|
-
- **Provider-neutral.** Use Anthropic
|
|
40
|
-
without changing the workflow.
|
|
39
|
+
- **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
|
|
40
|
+
a local/remote Ollama server without changing the workflow.
|
|
41
41
|
- **Lean by construction.** Jecode installs as plain JavaScript, runs on
|
|
42
42
|
Node.js 22.18+ (22.x) or Node.js 24+, executes no installation scripts, and
|
|
43
43
|
has zero third-party runtime dependencies.
|
|
@@ -145,15 +145,27 @@ published package runs no compilation or installation scripts.
|
|
|
145
145
|
## First session
|
|
146
146
|
|
|
147
147
|
Jecode opens on an empty composer instead of forcing a setup wizard. Type
|
|
148
|
-
**/settings** when you are ready to choose a provider, select a model, and
|
|
149
|
-
|
|
150
|
-
saved explicitly under **~/.jecode**; it is never stored in the
|
|
148
|
+
**/settings** when you are ready to choose a provider, select a model, and
|
|
149
|
+
configure authentication. An API key can remain in memory for the current
|
|
150
|
+
session or be saved explicitly under **~/.jecode**; it is never stored in the
|
|
151
|
+
workspace.
|
|
151
152
|
|
|
152
|
-
| Provider |
|
|
153
|
+
| Provider ID | Authentication | Notes |
|
|
153
154
|
|---|---|---|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
155
|
+
| anthropic | ANTHROPIC_API_KEY | Anthropic API |
|
|
156
|
+
| openai | OPENAI_API_KEY | OpenAI API |
|
|
157
|
+
| openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
|
|
158
|
+
| ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
|
|
159
|
+
|
|
160
|
+
Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
|
|
161
|
+
Jecode offers a local browser callback and a device-code flow; WSL and remote
|
|
162
|
+
terminals default to the device code. The connection is saved only after the
|
|
163
|
+
flow completes. Availability and usage limits are determined by the ChatGPT
|
|
164
|
+
account and plan, not by OpenAI API credits. This integration is experimental
|
|
165
|
+
and is not an endorsement of Jecode by OpenAI.
|
|
166
|
+
|
|
167
|
+
Anthropic remains API-key only. Jecode does not reuse a Claude consumer
|
|
168
|
+
subscription or copy credentials from another client.
|
|
157
169
|
|
|
158
170
|
Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
|
|
159
171
|
in **/settings**. Existing users with an Ollama API key automatically use
|
|
@@ -166,11 +178,11 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
166
178
|
|
|
167
179
|
| Command | What it does |
|
|
168
180
|
|---|---|
|
|
169
|
-
| /settings | Manage provider, connection, model, limits, motion, and
|
|
181
|
+
| /settings | Manage provider, connection, model, limits, motion, and authentication |
|
|
170
182
|
| /effort | Change and save reasoning effort directly |
|
|
171
183
|
| /providers | Switch the provider for the next turn |
|
|
172
184
|
| /models | Search the live model catalogue |
|
|
173
|
-
| /credentials |
|
|
185
|
+
| /credentials | Manage API keys and the connected ChatGPT account |
|
|
174
186
|
| /permissions | Manage session tool access and remembered approvals |
|
|
175
187
|
| /new | Start a clean conversation and reset session tool permissions |
|
|
176
188
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
@@ -207,14 +219,16 @@ settings, built-in defaults.
|
|
|
207
219
|
| --ollama-host | OLLAMA_HOST | Cloud with an Ollama key, local without one |
|
|
208
220
|
| --root | — | Current directory |
|
|
209
221
|
| --effort | JECODE_EFFORT | high |
|
|
210
|
-
| --max-tokens | JECODE_MAX_TOKENS | 64000 |
|
|
222
|
+
| --max-tokens | JECODE_MAX_TOKENS | 64000; not sent by openai-codex |
|
|
211
223
|
| --max-steps | JECODE_MAX_STEPS | 40 |
|
|
212
224
|
| --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
|
|
213
225
|
| --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
|
|
214
226
|
|
|
215
227
|
Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
|
|
229
|
+
separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
|
|
230
|
+
permissions where the operating system supports them. Environment API keys
|
|
231
|
+
always win.
|
|
218
232
|
|
|
219
233
|
Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
|
|
220
234
|
terminals and pipelines that disable colour.
|
|
@@ -244,6 +258,9 @@ untrusted data.
|
|
|
244
258
|
commands receive no credential-like environment variables, and recognized
|
|
245
259
|
credential values are redacted before tool output reaches the model, screen,
|
|
246
260
|
history, or export.
|
|
261
|
+
- ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
|
|
262
|
+
flow. Refresh-token rotation is serialized across Jecode processes; OAuth
|
|
263
|
+
tokens are withheld and redacted like API keys.
|
|
247
264
|
- Terminal control characters are neutralized before rendering.
|
|
248
265
|
- Remote Ollama endpoints require HTTPS. Provider HTTP redirects are rejected
|
|
249
266
|
rather than followed across an implicit trust boundary.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A tiny cross-process lock for rotating OAuth credentials.
|
|
2
|
+
//
|
|
3
|
+
// Refresh tokens may rotate after one use. Two Jecode processes refreshing
|
|
4
|
+
// the same account concurrently would make one of them persist a dead token,
|
|
5
|
+
// so account mutations serialize through an atomic lock directory.
|
|
6
|
+
import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
const WAIT_MS = 50;
|
|
10
|
+
const WAIT_LIMIT_MS = 20_000;
|
|
11
|
+
const STALE_MS = 60_000;
|
|
12
|
+
export async function withAccountLock(accountFile, body, signal) {
|
|
13
|
+
throwIfAborted(signal);
|
|
14
|
+
const directory = `${accountFile}.lock`;
|
|
15
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
while (!(await acquire(directory, token))) {
|
|
18
|
+
if (signal?.aborted === true)
|
|
19
|
+
throw abortReason(signal);
|
|
20
|
+
if (Date.now() - started >= WAIT_LIMIT_MS) {
|
|
21
|
+
throw new Error("timed out waiting for the account store");
|
|
22
|
+
}
|
|
23
|
+
await recoverStale(directory);
|
|
24
|
+
await wait(WAIT_MS, signal);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
throwIfAborted(signal);
|
|
28
|
+
return await body();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await release(directory, token);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function acquire(directory, token) {
|
|
35
|
+
let created = false;
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(directory, { mode: 0o700 });
|
|
38
|
+
created = true;
|
|
39
|
+
const owner = await open(path.join(directory, "owner"), "wx", 0o600);
|
|
40
|
+
try {
|
|
41
|
+
await owner.writeFile(token, "utf8");
|
|
42
|
+
await owner.sync();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await owner.close();
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!created && error.code === "EEXIST")
|
|
51
|
+
return false;
|
|
52
|
+
if (created)
|
|
53
|
+
await removeLock(directory);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function recoverStale(directory) {
|
|
58
|
+
try {
|
|
59
|
+
const details = await stat(directory);
|
|
60
|
+
if (Date.now() - details.mtimeMs < STALE_MS)
|
|
61
|
+
return;
|
|
62
|
+
const quarantined = `${directory}.${randomUUID()}.stale`;
|
|
63
|
+
await rename(directory, quarantined);
|
|
64
|
+
await unlink(path.join(quarantined, "owner")).catch(() => undefined);
|
|
65
|
+
await rmdir(quarantined).catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const code = error.code;
|
|
69
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function release(directory, token) {
|
|
74
|
+
try {
|
|
75
|
+
const owner = path.join(directory, "owner");
|
|
76
|
+
if ((await readFile(owner, "utf8")) !== token)
|
|
77
|
+
return;
|
|
78
|
+
await unlink(owner);
|
|
79
|
+
await rmdir(directory);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A recovered stale lock or an already-removed directory is no longer ours.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function removeLock(directory) {
|
|
86
|
+
await unlink(path.join(directory, "owner")).catch(() => undefined);
|
|
87
|
+
await rmdir(directory).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
function wait(ms, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted === true) {
|
|
92
|
+
reject(abortReason(signal));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
signal?.removeEventListener("abort", onAbort);
|
|
97
|
+
resolve();
|
|
98
|
+
}, ms);
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
|
|
102
|
+
};
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function abortReason(signal) {
|
|
107
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
108
|
+
}
|
|
109
|
+
function throwIfAborted(signal) {
|
|
110
|
+
if (signal?.aborted === true)
|
|
111
|
+
throw abortReason(signal);
|
|
112
|
+
}
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OAuth accounts persisted under ~/.jecode, apart from API keys.
|
|
2
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { atomicWrite } from "./atomic.js";
|
|
6
|
+
import { withAccountLock } from "./account-lock.js";
|
|
7
|
+
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
8
|
+
let cached;
|
|
9
|
+
export function openAICodexAccount() {
|
|
10
|
+
const account = store().accounts["openai-codex"];
|
|
11
|
+
return account === undefined ? undefined : { ...account };
|
|
12
|
+
}
|
|
13
|
+
export function accountValues() {
|
|
14
|
+
const account = store().accounts["openai-codex"];
|
|
15
|
+
return account === undefined ? [] : [account.accessToken, account.refreshToken];
|
|
16
|
+
}
|
|
17
|
+
export function accountsPath() {
|
|
18
|
+
return userDataPath("accounts.json");
|
|
19
|
+
}
|
|
20
|
+
export function accountsLabel() {
|
|
21
|
+
return userDataLabel("accounts.json");
|
|
22
|
+
}
|
|
23
|
+
export async function updateOpenAICodexAccount(change, signal) {
|
|
24
|
+
const file = accountsPath();
|
|
25
|
+
const directory = path.dirname(file);
|
|
26
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
await chmod(directory, 0o700);
|
|
29
|
+
return withAccountLock(file, async () => {
|
|
30
|
+
const current = readStore(file);
|
|
31
|
+
const next = await change(current.accounts["openai-codex"]);
|
|
32
|
+
const accounts = { ...current.accounts };
|
|
33
|
+
if (next === undefined)
|
|
34
|
+
delete accounts["openai-codex"];
|
|
35
|
+
else
|
|
36
|
+
accounts["openai-codex"] = { ...next };
|
|
37
|
+
const updated = { version: 1, accounts };
|
|
38
|
+
await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
cached = updated;
|
|
40
|
+
return next === undefined ? undefined : { ...next };
|
|
41
|
+
}, signal);
|
|
42
|
+
}
|
|
43
|
+
export function reloadAccounts() {
|
|
44
|
+
cached = undefined;
|
|
45
|
+
}
|
|
46
|
+
function store() {
|
|
47
|
+
if (cached === undefined)
|
|
48
|
+
cached = readStore(accountsPath());
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
function readStore(file) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
54
|
+
return normalize(parsed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { version: 1, accounts: {} };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalize(value) {
|
|
61
|
+
if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
|
|
62
|
+
return { version: 1, accounts: {} };
|
|
63
|
+
}
|
|
64
|
+
const account = normalizeOpenAI(value["accounts"]["openai-codex"]);
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
accounts: account === undefined ? {} : { "openai-codex": account },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function normalizeOpenAI(value) {
|
|
71
|
+
if (!record(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
const accessToken = nonempty(value["accessToken"]);
|
|
74
|
+
const refreshToken = nonempty(value["refreshToken"]);
|
|
75
|
+
const accountId = nonempty(value["accountId"]);
|
|
76
|
+
const expiresAt = value["expiresAt"];
|
|
77
|
+
if (accessToken === undefined ||
|
|
78
|
+
refreshToken === undefined ||
|
|
79
|
+
accountId === undefined ||
|
|
80
|
+
typeof expiresAt !== "number" ||
|
|
81
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
82
|
+
expiresAt <= 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const email = nonempty(value["email"]);
|
|
85
|
+
const plan = nonempty(value["plan"]);
|
|
86
|
+
return {
|
|
87
|
+
accessToken,
|
|
88
|
+
refreshToken,
|
|
89
|
+
expiresAt,
|
|
90
|
+
accountId,
|
|
91
|
+
...(email === undefined ? {} : { email }),
|
|
92
|
+
...(plan === undefined ? {} : { plan }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function nonempty(value) {
|
|
96
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
97
|
+
}
|
|
98
|
+
function record(value) {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
package/dist/cli-info.js
CHANGED
|
@@ -8,10 +8,10 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
Options:
|
|
10
10
|
--root <path> workspace root (default: current directory)
|
|
11
|
-
--provider <id> anthropic, openai, or ollama
|
|
11
|
+
--provider <id> anthropic, openai, openai-codex, or ollama
|
|
12
12
|
--model <id> model for the selected provider
|
|
13
13
|
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
14
|
-
--effort <level> low, medium, high, or max
|
|
14
|
+
--effort <level> low, medium, high, xhigh, or max
|
|
15
15
|
--max-tokens <number> output-token ceiling
|
|
16
16
|
--max-steps <number> tool-loop ceiling
|
|
17
17
|
--reduced-motion disable animated terminal states
|
package/dist/commands.js
CHANGED
|
@@ -24,7 +24,7 @@ export const COMMANDS = [
|
|
|
24
24
|
{ name: "permissions", blurb: "manage session tool access" },
|
|
25
25
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
26
26
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
27
|
-
{ name: "credentials", blurb: "
|
|
27
|
+
{ name: "credentials", blurb: "manage API keys and connected accounts" },
|
|
28
28
|
{ name: "models", blurb: "pick a model, from what the provider offers" },
|
|
29
29
|
{ name: "providers", blurb: "pick a provider" },
|
|
30
30
|
];
|
package/dist/controller.js
CHANGED
|
@@ -29,6 +29,9 @@ export async function runTurn(history, options, events, signal) {
|
|
|
29
29
|
onStatus: (status) => events.onStatus?.(status),
|
|
30
30
|
});
|
|
31
31
|
const calls = assistant.content.filter(isToolCall);
|
|
32
|
+
if (assistant.content.length === 0) {
|
|
33
|
+
throw new Error(`${options.provider.id} completed without an answer or tool call`);
|
|
34
|
+
}
|
|
32
35
|
if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
|
|
33
36
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
34
37
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
4
|
import { PROVIDERS } from "./providers/index.js";
|
|
5
|
+
import { openAIAccountHint } from "./openai-account.js";
|
|
6
|
+
import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
|
|
5
7
|
import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
6
8
|
/** Ask for a key, then ask separately whether it may be written to disk. */
|
|
7
9
|
export async function askForKey(name, host, pal) {
|
|
@@ -52,10 +54,12 @@ export async function credentialsCommand(session, host) {
|
|
|
52
54
|
if (choose === undefined)
|
|
53
55
|
return;
|
|
54
56
|
const index = await choose({
|
|
55
|
-
title: heading("
|
|
57
|
+
title: heading("authentication", "secrets are never shown", session.palette),
|
|
56
58
|
options: PROVIDERS.map((provider) => ({
|
|
57
|
-
label: provider.keyVar
|
|
58
|
-
hint:
|
|
59
|
+
label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
|
|
60
|
+
hint: provider.auth.kind === "api-key"
|
|
61
|
+
? credentialSource(provider.auth.keyVar) ?? "missing"
|
|
62
|
+
: openAIAccountHint(),
|
|
59
63
|
})),
|
|
60
64
|
index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
|
|
61
65
|
});
|
|
@@ -64,7 +68,11 @@ export async function credentialsCommand(session, host) {
|
|
|
64
68
|
const provider = PROVIDERS[index];
|
|
65
69
|
if (provider === undefined)
|
|
66
70
|
return;
|
|
67
|
-
|
|
71
|
+
if (provider.auth.kind === "oauth") {
|
|
72
|
+
await openAIAccountCommand(session, host);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const name = provider.auth.keyVar;
|
|
68
76
|
const source = credentialSource(name);
|
|
69
77
|
if (source === "environment") {
|
|
70
78
|
host.emit({
|
|
@@ -93,6 +101,26 @@ export async function credentialsCommand(session, host) {
|
|
|
93
101
|
}
|
|
94
102
|
await askForKey(name, host, session.palette);
|
|
95
103
|
}
|
|
104
|
+
/** Offer the authentication flow owned by a provider, if that is its blocker. */
|
|
105
|
+
export async function ensureProviderAuthentication(provider, session, host) {
|
|
106
|
+
const blocked = provider.blocked();
|
|
107
|
+
if (blocked === undefined)
|
|
108
|
+
return true;
|
|
109
|
+
if (provider.auth.kind === "oauth") {
|
|
110
|
+
return provider.auth.account === "openai-codex"
|
|
111
|
+
? ensureOpenAIAccount(session, host)
|
|
112
|
+
: false;
|
|
113
|
+
}
|
|
114
|
+
if (!blocked.startsWith(`${provider.auth.keyVar} `)) {
|
|
115
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
await askForKey(provider.auth.keyVar, host, session.palette);
|
|
119
|
+
return provider.blocked() === undefined;
|
|
120
|
+
}
|
|
121
|
+
export function authenticationNeed(provider) {
|
|
122
|
+
return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
|
|
123
|
+
}
|
|
96
124
|
async function offerForget(name, session, host, hint) {
|
|
97
125
|
if (host.choose === undefined)
|
|
98
126
|
return;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The shell needs a useful process environment, not the application's secrets.
|
|
2
2
|
import { credentialValues } from "./credentials.js";
|
|
3
|
+
import { accountValues } from "./accounts.js";
|
|
3
4
|
const REDACTED = "[credential redacted]";
|
|
4
5
|
const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
|
|
5
6
|
const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
|
|
@@ -60,7 +61,7 @@ function sensitiveEnvironmentName(name) {
|
|
|
60
61
|
COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
|
|
61
62
|
}
|
|
62
63
|
function secrets(source) {
|
|
63
|
-
const values = new Set(credentialValues());
|
|
64
|
+
const values = new Set([...credentialValues(), ...accountValues()]);
|
|
64
65
|
for (const [name, value] of Object.entries(source)) {
|
|
65
66
|
if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
|
|
66
67
|
values.add(value);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Open one HTTPS URL without involving a shell or interpolating commands.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
export async function openExternal(url) {
|
|
5
|
+
const target = new URL(url);
|
|
6
|
+
if (target.protocol !== "https:")
|
|
7
|
+
throw new Error("only HTTPS links may be opened");
|
|
8
|
+
const command = browserCommand(target.href);
|
|
9
|
+
if (command === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command.file, command.args, {
|
|
13
|
+
detached: true,
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
child.once("error", () => resolve(false));
|
|
18
|
+
child.once("spawn", () => {
|
|
19
|
+
child.unref();
|
|
20
|
+
resolve(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function browserCommand(url) {
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
27
|
+
}
|
|
28
|
+
if (process.platform === "darwin")
|
|
29
|
+
return { file: "open", args: [url] };
|
|
30
|
+
if (isWsl())
|
|
31
|
+
return { file: "explorer.exe", args: [url] };
|
|
32
|
+
return { file: "xdg-open", args: [url] };
|
|
33
|
+
}
|
|
34
|
+
export function headlessEnvironment() {
|
|
35
|
+
if (isWsl() || process.env["SSH_CONNECTION"] !== undefined || process.env["SSH_TTY"] !== undefined) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return process.platform === "linux" &&
|
|
39
|
+
process.env["DISPLAY"] === undefined &&
|
|
40
|
+
process.env["WAYLAND_DISPLAY"] === undefined;
|
|
41
|
+
}
|
|
42
|
+
function isWsl() {
|
|
43
|
+
if (process.platform !== "linux")
|
|
44
|
+
return false;
|
|
45
|
+
if (process.env["WSL_DISTRO_NAME"] !== undefined || process.env["WSL_INTEROP"] !== undefined)
|
|
46
|
+
return true;
|
|
47
|
+
try {
|
|
48
|
+
return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A narrow HTTP boundary for OAuth authority requests.
|
|
2
|
+
//
|
|
3
|
+
// These requests never redirect, never retry, and never retain an unbounded
|
|
4
|
+
// response. Authorization codes and refresh tokens must not leak into errors.
|
|
5
|
+
const AUTH_ORIGIN = "https://auth.openai.com";
|
|
6
|
+
const TIMEOUT_MS = 15_000;
|
|
7
|
+
const MAX_BODY_CHARS = 64_000;
|
|
8
|
+
export async function oauthRequest(url, body, signal, accepted = [200]) {
|
|
9
|
+
const target = new URL(url);
|
|
10
|
+
if (target.origin !== AUTH_ORIGIN || target.username !== "" || target.password !== "") {
|
|
11
|
+
throw new Error("OAuth request target is not allowed");
|
|
12
|
+
}
|
|
13
|
+
const timeout = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => timeout.abort(new Error("OpenAI sign-in timed out")), TIMEOUT_MS);
|
|
15
|
+
const combined = signal === undefined
|
|
16
|
+
? timeout.signal
|
|
17
|
+
: AbortSignal.any([signal, timeout.signal]);
|
|
18
|
+
const secrets = bodySecrets(body);
|
|
19
|
+
let response;
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(target, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": body.contentType, accept: "application/json" },
|
|
25
|
+
body: body.contentType === "application/json"
|
|
26
|
+
? JSON.stringify(body.value)
|
|
27
|
+
: body.value.toString(),
|
|
28
|
+
cache: "no-store",
|
|
29
|
+
redirect: "manual",
|
|
30
|
+
signal: combined,
|
|
31
|
+
});
|
|
32
|
+
if (response.status >= 300 && response.status < 400) {
|
|
33
|
+
await response.body?.cancel().catch(() => undefined);
|
|
34
|
+
throw new Error(`OpenAI sign-in redirect rejected (${response.status})`);
|
|
35
|
+
}
|
|
36
|
+
text = await boundedText(response);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (timeout.signal.aborted)
|
|
40
|
+
throw timeout.signal.reason;
|
|
41
|
+
if (signal?.aborted === true)
|
|
42
|
+
throw abortReason(signal);
|
|
43
|
+
if (error instanceof Error && error.message.startsWith("OpenAI sign-in"))
|
|
44
|
+
throw error;
|
|
45
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
46
|
+
throw new Error(`OpenAI sign-in network error: ${detail}`);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
const value = parse(text);
|
|
52
|
+
if (!accepted.includes(response.status)) {
|
|
53
|
+
throw new Error(`OpenAI sign-in failed (${response.status})${errorDetail(value, secrets)}`);
|
|
54
|
+
}
|
|
55
|
+
return { status: response.status, value };
|
|
56
|
+
}
|
|
57
|
+
async function boundedText(response) {
|
|
58
|
+
if (response.body === null)
|
|
59
|
+
return "";
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let text = "";
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done)
|
|
67
|
+
return text + decoder.decode();
|
|
68
|
+
text += decoder.decode(value, { stream: true });
|
|
69
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
70
|
+
await reader.cancel().catch(() => undefined);
|
|
71
|
+
throw new Error("OpenAI sign-in returned too much data");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parse(text) {
|
|
80
|
+
if (text.trim() === "")
|
|
81
|
+
return {};
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error("OpenAI sign-in returned an invalid response");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function errorDetail(value, secrets) {
|
|
90
|
+
if (!record(value))
|
|
91
|
+
return "";
|
|
92
|
+
const detail = [value["error_description"], value["message"], value["error"]]
|
|
93
|
+
.find((entry) => typeof entry === "string");
|
|
94
|
+
if (typeof detail !== "string" || detail.trim() === "")
|
|
95
|
+
return "";
|
|
96
|
+
let safe = detail.replace(/[\r\n]+/g, " ");
|
|
97
|
+
for (const secret of secrets)
|
|
98
|
+
safe = safe.replaceAll(secret, "[credential redacted]");
|
|
99
|
+
return ` · ${safe.slice(0, 300)}`;
|
|
100
|
+
}
|
|
101
|
+
function bodySecrets(body) {
|
|
102
|
+
const values = body.contentType === "application/x-www-form-urlencoded"
|
|
103
|
+
? [...body.value.values()]
|
|
104
|
+
: record(body.value)
|
|
105
|
+
? Object.values(body.value).filter((value) => typeof value === "string")
|
|
106
|
+
: [];
|
|
107
|
+
return values.filter((value) => value.length >= 8);
|
|
108
|
+
}
|
|
109
|
+
function abortReason(signal) {
|
|
110
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
111
|
+
}
|
|
112
|
+
function record(value) {
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|