@giovannijecha/jecode 0.8.4 → 0.8.6
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 +28 -9
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +39 -4
- package/dist/bounded-file.js +212 -0
- package/dist/cli-info.js +0 -1
- package/dist/commands.js +2 -0
- package/dist/config.js +16 -7
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +25 -9
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +23 -10
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +11 -37
- package/dist/provider-errors.js +31 -7
- package/dist/provider-label.js +9 -3
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -20
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +82 -23
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +36 -158
- package/dist/providers/openai-codex.js +71 -45
- package/dist/providers/openai-stream.js +26 -2
- package/dist/providers/openai.js +51 -25
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +183 -378
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +75 -22
- package/dist/stable-directory.js +148 -0
- package/dist/start.js +1 -2
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -171
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +66 -205
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +9 -334
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +14 -15
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tools/ripgrep.js +0 -230
- package/dist/tui/motion.js +0 -32
package/dist/credentials.js
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
// The file lives under ~/.jecode and never in this repo. A
|
|
10
10
|
// secret in the working tree is one `git add -A` from being published, which
|
|
11
11
|
// is why "not in the repo" is a rule and not a preference.
|
|
12
|
-
import { chmod, mkdir } from "node:fs/promises";
|
|
13
12
|
import * as path from "node:path";
|
|
14
13
|
import { atomicWrite } from "./atomic.js";
|
|
14
|
+
import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
|
|
15
15
|
import { withStoreLock } from "./store-lock.js";
|
|
16
16
|
import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
|
|
17
|
-
import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
|
|
17
|
+
import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
|
|
18
18
|
/** Keys this session was given but not asked to keep. Dies with the window. */
|
|
19
19
|
const held = new Map();
|
|
20
20
|
/** The saved file, read once. `undefined` until the first look at it. */
|
|
@@ -79,35 +79,37 @@ export function forgetSession(name) {
|
|
|
79
79
|
export async function keep(name, value) {
|
|
80
80
|
assertCredential(name, value);
|
|
81
81
|
const file = storePath();
|
|
82
|
-
await prepare(file);
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const directory = await prepare(file);
|
|
83
|
+
const anchoredFile = path.join(directory.path, path.basename(file));
|
|
84
|
+
return withStoreLock(anchoredFile, async () => {
|
|
85
|
+
const all = { ...readSavedStoreForMutation(anchoredFile, directory), [name]: value };
|
|
85
86
|
if (Object.keys(all).length > USER_STORE_LIMITS.credentialEntries) {
|
|
86
87
|
throw new Error("too many saved credentials");
|
|
87
88
|
}
|
|
88
|
-
await persist(
|
|
89
|
+
await persist(anchoredFile, all, directory);
|
|
89
90
|
saved = all;
|
|
90
91
|
// A newly saved replacement must become active immediately. Otherwise an
|
|
91
92
|
// older session-only value would keep shadowing the value just written.
|
|
92
93
|
held.delete(name);
|
|
93
94
|
return file;
|
|
94
|
-
});
|
|
95
|
+
}, undefined, async () => assertDirectoryAnchor(directory));
|
|
95
96
|
}
|
|
96
97
|
/** Remove only the saved copy. An environment or session value is untouched. */
|
|
97
98
|
export async function forgetSaved(name) {
|
|
98
99
|
const file = storePath();
|
|
99
|
-
await prepare(file);
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const directory = await prepare(file);
|
|
101
|
+
const anchoredFile = path.join(directory.path, path.basename(file));
|
|
102
|
+
return withStoreLock(anchoredFile, async () => {
|
|
103
|
+
const all = { ...readSavedStoreForMutation(anchoredFile, directory) };
|
|
102
104
|
if (use(all[name]) === undefined) {
|
|
103
105
|
saved = all;
|
|
104
106
|
return false;
|
|
105
107
|
}
|
|
106
108
|
delete all[name];
|
|
107
|
-
await persist(
|
|
109
|
+
await persist(anchoredFile, all, directory);
|
|
108
110
|
saved = all;
|
|
109
111
|
return true;
|
|
110
|
-
});
|
|
112
|
+
}, undefined, async () => assertDirectoryAnchor(directory));
|
|
111
113
|
}
|
|
112
114
|
export function storePath() {
|
|
113
115
|
return userDataPath("credentials.json");
|
|
@@ -137,9 +139,44 @@ function readSavedStore() {
|
|
|
137
139
|
const legacy = legacyUserDataPath("credentials.json");
|
|
138
140
|
return current ?? (legacy === undefined ? undefined : readStore(legacy)) ?? {};
|
|
139
141
|
}
|
|
142
|
+
function readSavedStoreForMutation(file, directory) {
|
|
143
|
+
const current = readCredentialStoreForMutation(file, directory);
|
|
144
|
+
if (current !== undefined)
|
|
145
|
+
return current;
|
|
146
|
+
const legacy = legacyUserDataPath("credentials.json");
|
|
147
|
+
if (legacy === undefined)
|
|
148
|
+
return {};
|
|
149
|
+
return readCredentialStoreForMutation(legacy) ?? {};
|
|
150
|
+
}
|
|
151
|
+
function readCredentialStoreForMutation(file, directory) {
|
|
152
|
+
let anchor = directory;
|
|
153
|
+
if (anchor === undefined) {
|
|
154
|
+
try {
|
|
155
|
+
anchor = captureDirectDirectorySync(path.dirname(file), "credential store directory");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === "ENOENT")
|
|
159
|
+
return undefined;
|
|
160
|
+
throw new Error("credential store is invalid, unsafe, or too large", { cause: error });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const anchoredFile = path.join(anchor.path, path.basename(file));
|
|
164
|
+
const value = readBoundedJsonForMutationSync(anchoredFile, USER_STORE_LIMITS.credentialsBytes, "credential store", anchor);
|
|
165
|
+
if (value === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (!record(value))
|
|
168
|
+
throw new Error("credential store has an unsupported structure");
|
|
169
|
+
const entries = Object.entries(value);
|
|
170
|
+
if (entries.length > USER_STORE_LIMITS.credentialEntries ||
|
|
171
|
+
entries.some(([name, candidate]) => !credential(name, candidate)))
|
|
172
|
+
throw new Error("credential store has invalid entries");
|
|
173
|
+
return Object.fromEntries(entries);
|
|
174
|
+
}
|
|
140
175
|
function readStore(file) {
|
|
141
176
|
try {
|
|
142
|
-
const
|
|
177
|
+
const directory = captureDirectDirectorySync(path.dirname(file), "credential store directory");
|
|
178
|
+
const anchoredFile = path.join(directory.path, path.basename(file));
|
|
179
|
+
const parsed = readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.credentialsBytes, directory);
|
|
143
180
|
if (!record(parsed))
|
|
144
181
|
return {};
|
|
145
182
|
// Anything that is not a string is not a key, whatever the file says.
|
|
@@ -156,14 +193,15 @@ function readStore(file) {
|
|
|
156
193
|
}
|
|
157
194
|
async function prepare(file) {
|
|
158
195
|
const directory = path.dirname(file);
|
|
159
|
-
|
|
160
|
-
if (process.platform !== "win32")
|
|
161
|
-
await chmod(directory, 0o700);
|
|
196
|
+
return preparePrivateDirectory(directory, "credential store directory");
|
|
162
197
|
}
|
|
163
|
-
async function persist(file, values) {
|
|
198
|
+
async function persist(file, values, directory) {
|
|
164
199
|
const text = `${JSON.stringify(values, null, 2)}\n`;
|
|
165
200
|
assertStoreText(text, USER_STORE_LIMITS.credentialsBytes);
|
|
166
|
-
await atomicWrite(file, text, {
|
|
201
|
+
await atomicWrite(file, text, {
|
|
202
|
+
mode: 0o600,
|
|
203
|
+
validate: async () => assertDirectoryAnchor(directory),
|
|
204
|
+
});
|
|
167
205
|
}
|
|
168
206
|
function assertCredential(name, value) {
|
|
169
207
|
if (!credential(name, value))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Stable ownership boundary for private data directories.
|
|
2
|
+
import { constants, lstatSync, realpath as realpathCallback, realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileIdentity, sameFileIdentity } from "./file-identity.js";
|
|
6
|
+
export async function preparePrivateDirectory(directory, label, mode = 0o700) {
|
|
7
|
+
const resolved = path.resolve(directory);
|
|
8
|
+
await mkdir(resolved, { recursive: true, mode });
|
|
9
|
+
const anchor = await captureDirectDirectory(resolved, label);
|
|
10
|
+
await secureDirectoryMode(anchor, mode);
|
|
11
|
+
return anchor;
|
|
12
|
+
}
|
|
13
|
+
export async function createPrivateDirectory(directory, label, mode = 0o700) {
|
|
14
|
+
const resolved = path.resolve(directory);
|
|
15
|
+
await mkdir(resolved, { mode });
|
|
16
|
+
const anchor = await captureDirectDirectory(resolved, label);
|
|
17
|
+
await secureDirectoryMode(anchor, mode);
|
|
18
|
+
return anchor;
|
|
19
|
+
}
|
|
20
|
+
export async function captureDirectDirectory(directory, label) {
|
|
21
|
+
const resolved = path.resolve(directory);
|
|
22
|
+
const [canonical, named] = await Promise.all([
|
|
23
|
+
nativeRealpath(resolved),
|
|
24
|
+
lstat(resolved, { bigint: true }),
|
|
25
|
+
]);
|
|
26
|
+
if (named.isSymbolicLink() || !named.isDirectory()) {
|
|
27
|
+
throw new Error(`${label} is not a direct directory`);
|
|
28
|
+
}
|
|
29
|
+
const direct = await lstat(canonical, { bigint: true });
|
|
30
|
+
if (direct.isSymbolicLink() || !direct.isDirectory() ||
|
|
31
|
+
!sameFileIdentity(fileIdentity(named), fileIdentity(direct)))
|
|
32
|
+
throw new Error(`${label} changed while it was anchored`);
|
|
33
|
+
return Object.freeze({ path: canonical, identity: fileIdentity(direct), label });
|
|
34
|
+
}
|
|
35
|
+
export function captureDirectDirectorySync(directory, label) {
|
|
36
|
+
const resolved = path.resolve(directory);
|
|
37
|
+
const canonical = realpathSync.native(resolved);
|
|
38
|
+
const named = lstatSync(resolved, { bigint: true });
|
|
39
|
+
if (named.isSymbolicLink() || !named.isDirectory()) {
|
|
40
|
+
throw new Error(`${label} is not a direct directory`);
|
|
41
|
+
}
|
|
42
|
+
const direct = lstatSync(canonical, { bigint: true });
|
|
43
|
+
if (direct.isSymbolicLink() || !direct.isDirectory() ||
|
|
44
|
+
!sameFileIdentity(fileIdentity(named), fileIdentity(direct)))
|
|
45
|
+
throw new Error(`${label} changed while it was anchored`);
|
|
46
|
+
return Object.freeze({ path: canonical, identity: fileIdentity(direct), label });
|
|
47
|
+
}
|
|
48
|
+
export async function assertDirectoryAnchor(anchor) {
|
|
49
|
+
const current = await lstat(anchor.path, { bigint: true });
|
|
50
|
+
if (current.isSymbolicLink() || !current.isDirectory() ||
|
|
51
|
+
!sameFileIdentity(anchor.identity, fileIdentity(current))) {
|
|
52
|
+
throw new Error(`${anchor.label} changed during use`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function assertDirectoryAnchorSync(anchor) {
|
|
56
|
+
const current = lstatSync(anchor.path, { bigint: true });
|
|
57
|
+
if (current.isSymbolicLink() || !current.isDirectory() ||
|
|
58
|
+
!sameFileIdentity(anchor.identity, fileIdentity(current))) {
|
|
59
|
+
throw new Error(`${anchor.label} changed during use`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function nativeRealpath(target) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
realpathCallback.native(target, (error, canonical) => {
|
|
65
|
+
if (error !== null)
|
|
66
|
+
reject(error);
|
|
67
|
+
else
|
|
68
|
+
resolve(canonical);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function secureDirectoryMode(anchor, mode) {
|
|
73
|
+
if (process.platform === "win32")
|
|
74
|
+
return;
|
|
75
|
+
const handle = await open(anchor.path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
76
|
+
try {
|
|
77
|
+
const opened = await handle.stat({ bigint: true });
|
|
78
|
+
if (!opened.isDirectory() ||
|
|
79
|
+
!sameFileIdentity(anchor.identity, fileIdentity(opened)))
|
|
80
|
+
throw new Error(`${anchor.label} changed before its permissions were secured`);
|
|
81
|
+
await handle.chmod(mode);
|
|
82
|
+
const secured = await handle.stat({ bigint: true });
|
|
83
|
+
if (!secured.isDirectory() ||
|
|
84
|
+
!sameFileIdentity(anchor.identity, fileIdentity(secured)))
|
|
85
|
+
throw new Error(`${anchor.label} changed while its permissions were secured`);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await handle.close();
|
|
89
|
+
}
|
|
90
|
+
await assertDirectoryAnchor(anchor);
|
|
91
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Portable-enough identity checks for named files and directories.
|
|
2
|
+
export function fileIdentity(details) {
|
|
3
|
+
return Object.freeze({
|
|
4
|
+
dev: details.dev,
|
|
5
|
+
ino: details.ino,
|
|
6
|
+
birthtimeNs: details.birthtimeNs,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export function sameFileIdentity(left, right) {
|
|
10
|
+
return left.dev === right.dev && left.ino === right.ino &&
|
|
11
|
+
left.birthtimeNs === right.birthtimeNs;
|
|
12
|
+
}
|
package/dist/model-command.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { saveCommandSettings } from "./command-settings.js";
|
|
3
3
|
import { compatibleEffort } from "./effort.js";
|
|
4
4
|
import { providerFailure } from "./provider-errors.js";
|
|
5
|
-
import { providerLabel } from "./provider-label.js";
|
|
5
|
+
import { providerLabel, providerRouteLabel } from "./provider-label.js";
|
|
6
6
|
import { PROVIDERS } from "./providers/index.js";
|
|
7
7
|
import { readSettings } from "./settings.js";
|
|
8
8
|
/** Build one searchable catalogue from every provider that is usable now. */
|
|
@@ -76,7 +76,7 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
|
|
|
76
76
|
label: choice.model,
|
|
77
77
|
// Provider identity is part of the choice, not optional help: keep it
|
|
78
78
|
// visible even when the terminal is only at the supported minimum.
|
|
79
|
-
value:
|
|
79
|
+
value: providerRouteLabel(choice.provider),
|
|
80
80
|
})),
|
|
81
81
|
searchable: true,
|
|
82
82
|
query: "",
|
|
@@ -111,7 +111,7 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
|
|
|
111
111
|
if (behavior.announce !== false) {
|
|
112
112
|
host.emit({
|
|
113
113
|
kind: "notice",
|
|
114
|
-
text: `model · ${
|
|
114
|
+
text: `model · ${providerRouteLabel(chosen.provider)} · ${chosen.model}`,
|
|
115
115
|
tone: "info",
|
|
116
116
|
});
|
|
117
117
|
}
|
|
@@ -138,6 +138,7 @@ async function alignEffort(provider, model, effort, host) {
|
|
|
138
138
|
}
|
|
139
139
|
function catalogDescription(disconnected, failed) {
|
|
140
140
|
const parts = [
|
|
141
|
+
"The connection on the right will run this model; API and account usage stay separate",
|
|
141
142
|
disconnected.length === 0
|
|
142
143
|
? undefined
|
|
143
144
|
: `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
|
|
@@ -145,7 +146,7 @@ function catalogDescription(disconnected, failed) {
|
|
|
145
146
|
? undefined
|
|
146
147
|
: `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
|
|
147
148
|
].filter((part) => part !== undefined);
|
|
148
|
-
return
|
|
149
|
+
return `${parts.join(" · ")} · manage access in /providers`;
|
|
149
150
|
}
|
|
150
151
|
function emptyCatalogMessage(connected, failed) {
|
|
151
152
|
if (failed.length === 1 && connected.length === 1) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Interactive
|
|
1
|
+
// Interactive account management for the OpenAI Codex provider.
|
|
2
2
|
import { openAICodexAccount } from "./accounts.js";
|
|
3
3
|
import { headlessEnvironment, openExternal } from "./external-browser.js";
|
|
4
4
|
import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
|
|
5
5
|
import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
|
|
6
|
+
import { providerLabel } from "./provider-label.js";
|
|
6
7
|
import { heading } from "./tui/picker.js";
|
|
8
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
7
9
|
const DEFAULT_DEPENDENCIES = {
|
|
8
10
|
beginBrowser: beginBrowserLogin,
|
|
9
11
|
beginDevice: beginDeviceLogin,
|
|
@@ -16,12 +18,12 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
|
|
|
16
18
|
const connected = openAICodexAccount() !== undefined;
|
|
17
19
|
const actions = connected
|
|
18
20
|
? [
|
|
19
|
-
{ label:
|
|
21
|
+
{ label: `reconnect ${ACCOUNT_LABEL}`, hint: "replace the current sign-in", key: "r" },
|
|
20
22
|
{ label: "sign out", hint: "remove the saved account", key: "s" },
|
|
21
23
|
]
|
|
22
|
-
: [{ label:
|
|
24
|
+
: [{ label: `connect ${ACCOUNT_LABEL}`, hint: "sign in with OpenAI", key: "c" }];
|
|
23
25
|
const action = await host.choose({
|
|
24
|
-
title: heading(
|
|
26
|
+
title: heading(ACCOUNT_LABEL, openAIAccountHint(), session.palette),
|
|
25
27
|
options: actions,
|
|
26
28
|
index: 0,
|
|
27
29
|
});
|
|
@@ -29,9 +31,14 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
|
|
|
29
31
|
return false;
|
|
30
32
|
if (connected && actions[action]?.key === "s") {
|
|
31
33
|
const result = await removeOpenAIAccount(host.signal);
|
|
34
|
+
const route = session.provider.id === "openai-codex"
|
|
35
|
+
? " · choose another provider in /models"
|
|
36
|
+
: "";
|
|
32
37
|
host.emit({
|
|
33
38
|
kind: "notice",
|
|
34
|
-
text: result.revokeFailed
|
|
39
|
+
text: result.revokeFailed
|
|
40
|
+
? `${ACCOUNT_LABEL} disconnected · remote sign-out could not be confirmed${route}`
|
|
41
|
+
: `${ACCOUNT_LABEL} disconnected${route}`,
|
|
35
42
|
tone: result.revokeFailed ? "warn" : "info",
|
|
36
43
|
});
|
|
37
44
|
return result.removed;
|
|
@@ -51,14 +58,14 @@ async function connectOpenAIAccount(session, host, dependencies) {
|
|
|
51
58
|
{ label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
|
|
52
59
|
];
|
|
53
60
|
const choice = await host.choose({
|
|
54
|
-
title: heading(
|
|
61
|
+
title: heading(`connect ${ACCOUNT_LABEL}`, "OpenAI OAuth", session.palette),
|
|
55
62
|
description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
|
|
56
63
|
options: methods,
|
|
57
64
|
index: dependencies.headless() ? 1 : 0,
|
|
58
65
|
});
|
|
59
66
|
if (choice === undefined)
|
|
60
67
|
return false;
|
|
61
|
-
host.status?.(
|
|
68
|
+
host.status?.(`Starting ${ACCOUNT_LABEL} sign-in`);
|
|
62
69
|
let login;
|
|
63
70
|
try {
|
|
64
71
|
login = choice === 1
|
|
@@ -89,7 +96,13 @@ async function waitForLogin(login, session, host, openUrl) {
|
|
|
89
96
|
if (outcome.kind === "account") {
|
|
90
97
|
host.dismiss?.();
|
|
91
98
|
await saveOpenAIAccount(outcome.account, host.signal);
|
|
92
|
-
host.emit({
|
|
99
|
+
host.emit({
|
|
100
|
+
kind: "notice",
|
|
101
|
+
text: session.provider.id === "openai-codex"
|
|
102
|
+
? `${ACCOUNT_LABEL} connected · current provider route`
|
|
103
|
+
: `${ACCOUNT_LABEL} connected · choose an ${ACCOUNT_LABEL} model in /models to use this account`,
|
|
104
|
+
tone: "info",
|
|
105
|
+
});
|
|
93
106
|
return true;
|
|
94
107
|
}
|
|
95
108
|
if (outcome.kind === "error") {
|
|
@@ -100,7 +113,7 @@ async function waitForLogin(login, session, host, openUrl) {
|
|
|
100
113
|
await openUrl(login.url);
|
|
101
114
|
continue;
|
|
102
115
|
}
|
|
103
|
-
local.abort(new Error(
|
|
116
|
+
local.abort(new Error(`${ACCOUNT_LABEL} sign-in cancelled`));
|
|
104
117
|
return false;
|
|
105
118
|
}
|
|
106
119
|
}
|
|
@@ -113,7 +126,7 @@ function waitingPicker(login, session) {
|
|
|
113
126
|
? "Finish sign-in in your browser. Jecode will continue automatically."
|
|
114
127
|
: `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
|
|
115
128
|
return {
|
|
116
|
-
title: heading(
|
|
129
|
+
title: heading(`${ACCOUNT_LABEL} sign-in`, login.code ?? "waiting for browser", session.palette),
|
|
117
130
|
description: detail,
|
|
118
131
|
options: [
|
|
119
132
|
{ label: "open browser again", key: "o" },
|
package/dist/openai-account.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The live
|
|
1
|
+
// The live OpenAI account: persistence, proactive refresh, and logout.
|
|
2
2
|
import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
|
|
3
3
|
import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
|
|
4
|
+
import { providerLabel } from "./provider-label.js";
|
|
5
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
4
6
|
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
5
7
|
let refreshing;
|
|
6
8
|
export function openAIAccountHint() {
|
|
@@ -33,7 +35,7 @@ export async function removeOpenAIAccount(signal) {
|
|
|
33
35
|
export async function openAIAuthorization(rejectedToken, signal, onStatus) {
|
|
34
36
|
const account = openAICodexAccount();
|
|
35
37
|
if (account === undefined)
|
|
36
|
-
throw new Error(
|
|
38
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
37
39
|
const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
|
|
38
40
|
const ready = mustRefresh
|
|
39
41
|
? await refreshAccount(rejectedToken, signal, onStatus)
|
|
@@ -47,14 +49,14 @@ async function refreshAccount(rejectedToken, signal, onStatus) {
|
|
|
47
49
|
throw abortReason(signal);
|
|
48
50
|
const task = updateOpenAICodexAccount(async (current) => {
|
|
49
51
|
if (current === undefined)
|
|
50
|
-
throw new Error(
|
|
52
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
51
53
|
if (!requiresAuthorityRefresh(current, rejectedToken))
|
|
52
54
|
return current;
|
|
53
|
-
onStatus?.(
|
|
55
|
+
onStatus?.(`Refreshing ${ACCOUNT_LABEL} sign-in`);
|
|
54
56
|
return refreshOpenAITokens(current);
|
|
55
57
|
}).then((account) => {
|
|
56
58
|
if (account === undefined)
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`${ACCOUNT_LABEL} is not connected`);
|
|
58
60
|
return account;
|
|
59
61
|
});
|
|
60
62
|
refreshing = task;
|
|
@@ -3,6 +3,8 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { leadingText } from "./text-boundary.js";
|
|
6
|
+
import { providerLabel } from "./provider-label.js";
|
|
7
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
6
8
|
const CALLBACK_PORTS = [1455, 1457];
|
|
7
9
|
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
8
10
|
export async function openAICallback(state) {
|
|
@@ -26,7 +28,14 @@ export async function openAICallback(state) {
|
|
|
26
28
|
outgoing.writeHead(400).end("Invalid sign-in callback.");
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
|
-
|
|
31
|
+
let incoming;
|
|
32
|
+
try {
|
|
33
|
+
incoming = new URL(request.url, "http://localhost");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
outgoing.writeHead(400).end("Invalid sign-in callback.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
30
39
|
if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
|
|
31
40
|
outgoing.writeHead(404).end("Not found.");
|
|
32
41
|
return;
|
|
@@ -43,10 +52,10 @@ export async function openAICallback(state) {
|
|
|
43
52
|
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
44
53
|
const authorizationCode = incoming.searchParams.get("code");
|
|
45
54
|
if (authError !== null) {
|
|
46
|
-
rejectCode(new Error(
|
|
55
|
+
rejectCode(new Error(`${ACCOUNT_LABEL} sign-in was rejected · ${leadingText(authError, 300)}`));
|
|
47
56
|
}
|
|
48
57
|
else if (authorizationCode === null || authorizationCode === "") {
|
|
49
|
-
rejectCode(new Error(
|
|
58
|
+
rejectCode(new Error(`${ACCOUNT_LABEL} sign-in returned no authorization code`));
|
|
50
59
|
}
|
|
51
60
|
else {
|
|
52
61
|
resolveCode(authorizationCode);
|
|
@@ -94,7 +103,7 @@ async function firstAvailableServer(handler) {
|
|
|
94
103
|
throw error;
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
|
-
throw new Error(
|
|
106
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in could not open callback ports 1455 or 1457`);
|
|
98
107
|
}
|
|
99
108
|
function listen(server, port) {
|
|
100
109
|
return new Promise((resolve, reject) => {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// Validate OAuth token responses and extract the ChatGPT account claims.
|
|
2
|
+
import { providerLabel } from "./provider-label.js";
|
|
3
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
2
4
|
const CLAIMS = "https://api.openai.com/auth";
|
|
3
5
|
export function openAITokenReply(value, previousRefresh) {
|
|
4
6
|
if (!record(value))
|
|
5
|
-
throw new Error(
|
|
7
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an invalid token response`);
|
|
6
8
|
const accessToken = required(value["access_token"], "access token");
|
|
7
9
|
const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
|
|
8
10
|
const expiresIn = value["expires_in"];
|
|
9
11
|
if (refreshToken === undefined)
|
|
10
|
-
throw new Error(
|
|
12
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not return a refresh token`);
|
|
11
13
|
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
12
|
-
throw new Error(
|
|
14
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not return a valid token lifetime`);
|
|
13
15
|
}
|
|
14
16
|
const idToken = optional(value["id_token"]);
|
|
15
17
|
return {
|
|
@@ -25,7 +27,7 @@ export function openAIAccountFromTokens(token) {
|
|
|
25
27
|
const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
|
|
26
28
|
const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
|
|
27
29
|
if (accountId === undefined)
|
|
28
|
-
throw new Error(
|
|
30
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in did not identify an account`);
|
|
29
31
|
const email = optional(identity?.["email"]);
|
|
30
32
|
const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
|
|
31
33
|
return {
|
|
@@ -40,7 +42,7 @@ export function openAIAccountFromTokens(token) {
|
|
|
40
42
|
function jwt(token) {
|
|
41
43
|
const part = token.split(".")[1];
|
|
42
44
|
if (part === undefined)
|
|
43
|
-
throw new Error(
|
|
45
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
|
|
44
46
|
try {
|
|
45
47
|
const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
46
48
|
if (!record(value))
|
|
@@ -48,13 +50,13 @@ function jwt(token) {
|
|
|
48
50
|
return value;
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
|
-
throw new Error(
|
|
53
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned an unreadable token`);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
function required(value, label) {
|
|
55
57
|
const found = optional(value);
|
|
56
58
|
if (found === undefined)
|
|
57
|
-
throw new Error(
|
|
59
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
|
|
58
60
|
return found;
|
|
59
61
|
}
|
|
60
62
|
function optional(value) {
|
package/dist/openai-oauth.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// an account on disk.
|
|
6
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
7
|
import { oauthRequest } from "./oauth-http.js";
|
|
8
|
+
import { providerLabel } from "./provider-label.js";
|
|
8
9
|
import { OPENAI_CALLBACK_PATH, openAICallback } from "./openai-oauth-callback.js";
|
|
9
10
|
import { openAIAccountFromTokens, openAITokenReply, } from "./openai-oauth-tokens.js";
|
|
11
|
+
const ACCOUNT_LABEL = providerLabel("openai-codex");
|
|
10
12
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
11
13
|
const AUTHORITY = "https://auth.openai.com";
|
|
12
14
|
const AUTHORIZE = `${AUTHORITY}/oauth/authorize`;
|
|
@@ -91,7 +93,7 @@ export async function refreshOpenAITokens(account, signal) {
|
|
|
91
93
|
const token = openAITokenReply(response.value, account.refreshToken);
|
|
92
94
|
const refreshed = openAIAccountFromTokens(token);
|
|
93
95
|
if (refreshed.accountId !== account.accountId) {
|
|
94
|
-
throw new Error(
|
|
96
|
+
throw new Error(`${ACCOUNT_LABEL} refresh returned a different account · sign in again`);
|
|
95
97
|
}
|
|
96
98
|
return {
|
|
97
99
|
...refreshed,
|
|
@@ -125,7 +127,7 @@ async function exchange(code, signal) {
|
|
|
125
127
|
async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
126
128
|
const deadline = new AbortController();
|
|
127
129
|
const timer = setTimeout(() => {
|
|
128
|
-
deadline.abort(new Error(
|
|
130
|
+
deadline.abort(new Error(`${ACCOUNT_LABEL} device sign-in timed out after 15 minutes`));
|
|
129
131
|
}, LOGIN_LIMIT_MS);
|
|
130
132
|
const combined = signal === undefined
|
|
131
133
|
? deadline.signal
|
|
@@ -149,10 +151,10 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
|
149
151
|
}
|
|
150
152
|
const errorCode = deviceErrorCode(response.value);
|
|
151
153
|
if (errorCode === "access_denied") {
|
|
152
|
-
throw new Error(
|
|
154
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in was denied`);
|
|
153
155
|
}
|
|
154
156
|
if (errorCode === "expired_token") {
|
|
155
|
-
throw new Error(
|
|
157
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in code expired`);
|
|
156
158
|
}
|
|
157
159
|
const pending = errorCode === "authorization_pending" ||
|
|
158
160
|
errorCode === "deviceauth_authorization_pending" ||
|
|
@@ -160,7 +162,7 @@ async function pollDevice(deviceAuthId, userCode, interval, signal) {
|
|
|
160
162
|
const slowDown = errorCode === "slow_down" ||
|
|
161
163
|
(response.status === 429 && errorCode === undefined);
|
|
162
164
|
if (!pending && !slowDown) {
|
|
163
|
-
throw new Error(
|
|
165
|
+
throw new Error(`${ACCOUNT_LABEL} device sign-in failed (${response.status})`);
|
|
164
166
|
}
|
|
165
167
|
if (slowDown)
|
|
166
168
|
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
@@ -192,7 +194,7 @@ function intervalSeconds(value) {
|
|
|
192
194
|
function required(value, label) {
|
|
193
195
|
const found = optional(value);
|
|
194
196
|
if (found === undefined)
|
|
195
|
-
throw new Error(
|
|
197
|
+
throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
|
|
196
198
|
return found;
|
|
197
199
|
}
|
|
198
200
|
function optional(value) {
|
|
@@ -42,7 +42,7 @@ export function permissionsPicker(tools, index = 0, adjust) {
|
|
|
42
42
|
index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
function permissionControlPicker(control, host, selected) {
|
|
45
|
+
export function permissionControlPicker(control, host, selected) {
|
|
46
46
|
return permissionsPicker(control.listTools(), selected, (index, step) => {
|
|
47
47
|
const tool = control.listTools()[index];
|
|
48
48
|
if (tool === undefined)
|