@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.
Files changed (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -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
- return withStoreLock(file, async () => {
84
- const all = { ...readSavedStore(), [name]: value };
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(file, all);
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
- return withStoreLock(file, async () => {
101
- const all = { ...readSavedStore() };
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(file, all);
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 parsed = readBoundedJsonSync(file, USER_STORE_LIMITS.credentialsBytes);
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
- await mkdir(directory, { recursive: true, mode: 0o700 });
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, { mode: 0o600 });
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
+ }
@@ -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: providerLabel(choice.provider.id),
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 · ${providerLabel(chosen.provider.id)} · ${chosen.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 parts.length === 0 ? undefined : `${parts.join(" · ")} · manage in /providers`;
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 ChatGPT account management for the OpenAI Codex provider.
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: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
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: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
24
+ : [{ label: `connect ${ACCOUNT_LABEL}`, hint: "sign in with OpenAI", key: "c" }];
23
25
  const action = await host.choose({
24
- title: heading("ChatGPT", openAIAccountHint(), session.palette),
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 ? "ChatGPT disconnected · remote sign-out could not be confirmed" : "ChatGPT disconnected",
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("connect ChatGPT", "OpenAI OAuth", session.palette),
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?.("Starting ChatGPT sign-in");
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({ kind: "notice", text: "ChatGPT connected", tone: "info" });
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("ChatGPT sign-in cancelled"));
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("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
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" },
@@ -1,6 +1,8 @@
1
- // The live ChatGPT account: persistence, proactive refresh, and logout.
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("ChatGPT account is not connected");
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("ChatGPT account is not connected");
52
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
51
53
  if (!requiresAuthorityRefresh(current, rejectedToken))
52
54
  return current;
53
- onStatus?.("Refreshing ChatGPT sign-in");
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("ChatGPT account is not connected");
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
- const incoming = new URL(request.url, "http://localhost");
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(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
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("ChatGPT sign-in returned no authorization code"));
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("ChatGPT sign-in could not open callback ports 1455 or 1457");
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("OpenAI sign-in returned an invalid token response");
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("OpenAI sign-in did not return a refresh token");
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("OpenAI sign-in did not return a valid token lifetime");
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("OpenAI sign-in did not identify a ChatGPT account");
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("OpenAI sign-in returned an unreadable token");
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("OpenAI sign-in returned an unreadable token");
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(`OpenAI sign-in returned no ${label}`);
59
+ throw new Error(`${ACCOUNT_LABEL} sign-in returned no ${label}`);
58
60
  return found;
59
61
  }
60
62
  function optional(value) {
@@ -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("OpenAI refreshed a different ChatGPT account · sign in again");
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("ChatGPT device sign-in timed out after 15 minutes"));
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("ChatGPT device sign-in was denied");
154
+ throw new Error(`${ACCOUNT_LABEL} device sign-in was denied`);
153
155
  }
154
156
  if (errorCode === "expired_token") {
155
- throw new Error("ChatGPT device sign-in code expired");
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(`ChatGPT device sign-in failed (${response.status})`);
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(`OpenAI sign-in returned no ${label}`);
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)