@aipoch/open-science 0.0.0-stage → 1.0.2

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.
@@ -0,0 +1,298 @@
1
+ /* eslint-disable @typescript-eslint/explicit-function-return-type */
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { access, mkdir, readFile } from 'node:fs/promises'
5
+ import { isAbsolute, join, normalize } from 'node:path'
6
+
7
+ import { resolveConfigRoot } from './config-root.mjs'
8
+ import { locateBackend } from './locate-backend.mjs'
9
+ import { connectToOpenScience } from './index.mjs'
10
+
11
+ const CODEX_CONFIG_OVERRIDE = 'cli_auth_credentials_store="file"'
12
+ const CODEX_ENV_KEYS = [
13
+ 'CODEX_API_KEY',
14
+ 'OPENAI_API_KEY',
15
+ 'CODEX_CONFIG',
16
+ 'CODEX_HOME',
17
+ 'CODEX_PATH',
18
+ 'DEFAULT_AUTH_REQUEST',
19
+ 'HOME',
20
+ 'MODEL_PROVIDER',
21
+ 'NO_BROWSER',
22
+ 'USERPROFILE'
23
+ ]
24
+ const PROXY_ENV_KEYS = [
25
+ 'HTTP_PROXY',
26
+ 'HTTPS_PROXY',
27
+ 'http_proxy',
28
+ 'https_proxy',
29
+ 'ALL_PROXY',
30
+ 'all_proxy',
31
+ 'NO_PROXY',
32
+ 'no_proxy'
33
+ ]
34
+ const PROXY_SERVER_ENV_KEYS = PROXY_ENV_KEYS.slice(0, 6)
35
+ const LOOPBACK_PROXY_BYPASS = [
36
+ 'localhost',
37
+ '.localhost',
38
+ '127.0.0.1',
39
+ '127.0.0.0/8',
40
+ '::1',
41
+ '[::1]'
42
+ ]
43
+ const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks:', 'socks4:', 'socks5:'])
44
+
45
+ export class CodexLoginError extends Error {
46
+ constructor(message, code = 'codex_login_failed', exitCode = 1) {
47
+ super(message)
48
+ this.name = 'CodexLoginError'
49
+ this.code = code
50
+ this.exitCode = exitCode
51
+ }
52
+ }
53
+
54
+ const normalizedProxySettings = (value) => {
55
+ if (!value || typeof value !== 'object') return { mode: 'system' }
56
+ if (value.mode === 'direct') return { mode: 'direct' }
57
+ if (value.mode !== 'manual' || typeof value.server !== 'string') return { mode: 'system' }
58
+
59
+ try {
60
+ const url = new URL(value.server.trim())
61
+ if (
62
+ !SUPPORTED_PROXY_PROTOCOLS.has(url.protocol) ||
63
+ !url.hostname ||
64
+ url.username ||
65
+ url.password ||
66
+ (url.pathname !== '' && url.pathname !== '/') ||
67
+ url.search ||
68
+ url.hash
69
+ ) {
70
+ return { mode: 'system' }
71
+ }
72
+ const bypassRules =
73
+ typeof value.bypassRules === 'string'
74
+ ? value.bypassRules
75
+ .split(/[,;\n]/)
76
+ .map((entry) => entry.trim())
77
+ .filter(Boolean)
78
+ : []
79
+ return {
80
+ mode: 'manual',
81
+ server: `${url.protocol}//${url.host}`,
82
+ bypassRules
83
+ }
84
+ } catch {
85
+ return { mode: 'system' }
86
+ }
87
+ }
88
+
89
+ const withLoopbackProxyBypass = (env, configuredRules = []) => {
90
+ const inheritedRules = [env.NO_PROXY, env.no_proxy]
91
+ .flatMap((value) => value?.split(',') ?? [])
92
+ .map((entry) => entry.trim())
93
+ .filter(Boolean)
94
+ const bypass = [
95
+ ...new Set([...configuredRules, ...inheritedRules, ...LOOPBACK_PROXY_BYPASS])
96
+ ].join(',')
97
+ env.NO_PROXY = bypass
98
+ env.no_proxy = bypass
99
+ }
100
+
101
+ export const applyCodexLoginProxyPolicy = (env, value) => {
102
+ const settings = normalizedProxySettings(value)
103
+ if (settings.mode === 'system') {
104
+ if (PROXY_SERVER_ENV_KEYS.some((key) => Boolean(env[key]))) withLoopbackProxyBypass(env)
105
+ return env
106
+ }
107
+
108
+ for (const key of PROXY_ENV_KEYS) delete env[key]
109
+ if (settings.mode === 'direct') return env
110
+
111
+ for (const key of PROXY_SERVER_ENV_KEYS) env[key] = settings.server
112
+ withLoopbackProxyBypass(env, settings.bypassRules)
113
+ return env
114
+ }
115
+
116
+ export const createCodexLoginEnvironment = (
117
+ codexHome,
118
+ sourceEnv = process.env,
119
+ platform = process.platform,
120
+ networkProxy
121
+ ) => {
122
+ const env = { ...sourceEnv }
123
+ for (const key of CODEX_ENV_KEYS) delete env[key]
124
+ env.CODEX_HOME = codexHome
125
+ env.HOME = codexHome
126
+ if (platform === 'win32') env.USERPROFILE = codexHome
127
+ return applyCodexLoginProxyPolicy(env, networkProxy)
128
+ }
129
+
130
+ export const resolveCodexLoginConfiguration = async (configRoot, dependencies = {}) => {
131
+ const deps = {
132
+ readFile: (path) => readFile(path, 'utf8'),
133
+ access: (path) => access(path),
134
+ ...dependencies
135
+ }
136
+ let settings
137
+ try {
138
+ settings = JSON.parse(await deps.readFile(join(configRoot, 'settings.json')))
139
+ } catch (error) {
140
+ if (error?.code === 'ENOENT') {
141
+ throw new CodexLoginError(
142
+ 'Codex is not configured for this Open-Science profile. Configure the Codex runtime first.',
143
+ 'codex_not_configured'
144
+ )
145
+ }
146
+ throw new CodexLoginError(
147
+ 'Open-Science could not read the configured Codex runtime.',
148
+ 'codex_configuration_invalid'
149
+ )
150
+ }
151
+
152
+ const nativePath =
153
+ typeof settings?.codex?.nativePath === 'string' ? settings.codex.nativePath.trim() : ''
154
+ if (!nativePath || !isAbsolute(nativePath)) {
155
+ throw new CodexLoginError(
156
+ 'The configured Codex runtime has no native CLI path. Repair the Codex installation first.',
157
+ 'codex_not_configured'
158
+ )
159
+ }
160
+
161
+ const resolvedPath = normalize(nativePath)
162
+ try {
163
+ await deps.access(resolvedPath)
164
+ } catch {
165
+ throw new CodexLoginError(
166
+ 'The configured Codex CLI does not exist: ' + resolvedPath,
167
+ 'codex_not_found'
168
+ )
169
+ }
170
+ return { codexPath: resolvedPath, networkProxy: settings.networkProxy }
171
+ }
172
+
173
+ export const resolveConfiguredCodexNativePath = async (configRoot, dependencies = {}) =>
174
+ (await resolveCodexLoginConfiguration(configRoot, dependencies)).codexPath
175
+
176
+ export const runCodexProcess = (codexPath, args, options = {}) =>
177
+ new Promise((resolveRun, rejectRun) => {
178
+ const needsShell = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(codexPath)
179
+ const child = spawn(needsShell ? '"' + codexPath + '"' : codexPath, args, {
180
+ env: options.env,
181
+ shell: needsShell,
182
+ stdio: options.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
183
+ windowsHide: true
184
+ })
185
+ let stdout = ''
186
+ let stderr = ''
187
+ if (!options.inherit) {
188
+ child.stdout?.setEncoding('utf8')
189
+ child.stderr?.setEncoding('utf8')
190
+ child.stdout?.on('data', (chunk) => {
191
+ stdout += chunk
192
+ })
193
+ child.stderr?.on('data', (chunk) => {
194
+ stderr += chunk
195
+ })
196
+ }
197
+ child.once('error', rejectRun)
198
+ child.once('exit', (code, signal) => resolveRun({ code, signal, stdout, stderr }))
199
+ })
200
+
201
+ const DEFAULT_DEPS = {
202
+ connect: connectToOpenScience,
203
+ locateBackend: () => locateBackend({ required: false }),
204
+ resolveConfigRoot: (options) => resolveConfigRoot(options),
205
+ resolveConfiguration: (configRoot) => resolveCodexLoginConfiguration(configRoot),
206
+ mkdir: (path) => mkdir(path, { recursive: true }),
207
+ runCodex: runCodexProcess,
208
+ log: (...args) => console.log(...args)
209
+ }
210
+
211
+ export const codexLoginCommand = async (options, dependencies = {}) => {
212
+ const deps = { ...DEFAULT_DEPS, ...dependencies }
213
+ const backend = await deps.locateBackend()
214
+ const configRoot = deps.resolveConfigRoot({
215
+ packaged: !backend.development,
216
+ override: options.configRoot
217
+ })
218
+ let client
219
+ try {
220
+ client = await deps.connect({ configRoot })
221
+ } catch (error) {
222
+ if (error?.code !== 'daemon_unavailable' || error?.status !== undefined) throw error
223
+ // Already-configured profiles retain offline terminal login. First-run writes need the owner.
224
+ try {
225
+ await deps.resolveConfiguration(configRoot)
226
+ } catch {
227
+ throw new CodexLoginError(
228
+ 'Start Open-Science first: open-science start --no-open; then retry codex login.',
229
+ 'daemon_unavailable'
230
+ )
231
+ }
232
+ }
233
+ const bootstrap = async (action) => {
234
+ if (!client) return
235
+ const result = await client.bootstrap({ action }, { timeoutMs: 600_000 })
236
+ if (!result.ok) throw new CodexLoginError(`Codex setup failed: ${result.code}.`, result.code)
237
+ }
238
+ try {
239
+ await bootstrap('codex-prepare')
240
+ } catch (error) {
241
+ if (error?.status !== 404) throw error
242
+ // Older daemons cannot register readiness, but configured native login remains available.
243
+ try {
244
+ await deps.resolveConfiguration(configRoot)
245
+ } catch {
246
+ throw new CodexLoginError(
247
+ 'Update and restart Open-Science before setting up Codex for this profile.',
248
+ 'bootstrap_unavailable'
249
+ )
250
+ }
251
+ client = undefined
252
+ }
253
+ const complete = async () => {
254
+ if (client) await bootstrap('codex-complete')
255
+ else
256
+ deps.log(
257
+ 'Sign-in saved. Use a running, updated Open-Science daemon and run codex login again to validate and activate the provider.'
258
+ )
259
+ }
260
+ const { codexPath, networkProxy } = await deps.resolveConfiguration(configRoot)
261
+ const codexHome = join(configRoot, 'codex-subscription')
262
+ await deps.mkdir(codexHome)
263
+ const env = createCodexLoginEnvironment(codexHome, process.env, process.platform, networkProxy)
264
+ const baseArgs = ['-c', CODEX_CONFIG_OVERRIDE, 'login']
265
+
266
+ if (!options.force) {
267
+ const status = await deps.runCodex(codexPath, [...baseArgs, 'status'], {
268
+ env,
269
+ inherit: false
270
+ })
271
+ if (status.code === 0) {
272
+ await complete()
273
+ deps.log(
274
+ 'Codex is already signed in for Open-Science. Use "open-science codex login --force" to sign in again.'
275
+ )
276
+ return
277
+ }
278
+ if (status.code !== 1 || status.signal) {
279
+ throw new CodexLoginError('Open-Science could not check the existing Codex sign-in.')
280
+ }
281
+ }
282
+
283
+ deps.log('Starting Codex device-code sign-in. Keep this terminal open until it completes...')
284
+ const login = await deps.runCodex(codexPath, [...baseArgs, '--device-auth'], {
285
+ env,
286
+ inherit: true
287
+ })
288
+ if (login.code !== 0 || login.signal) {
289
+ const suffix = login.signal ? ' after receiving ' + login.signal : ''
290
+ throw new CodexLoginError(
291
+ 'Codex device-code sign-in did not complete' + suffix + '.',
292
+ 'codex_login_failed',
293
+ login.code ?? 1
294
+ )
295
+ }
296
+ await complete()
297
+ deps.log('Codex is signed in for Open-Science.')
298
+ }
@@ -0,0 +1,65 @@
1
+ /* eslint-disable @typescript-eslint/explicit-function-return-type */
2
+
3
+ import { access, readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { isAbsolute, join, normalize } from 'node:path'
6
+
7
+ export const DEV_CONFIG_DIR = '.open-science-project'
8
+ export const PROD_CONFIG_DIR = '.open-science'
9
+ export const STATE_FILE = 'web-service.json'
10
+ export const TOKEN_FILE = 'web-token'
11
+
12
+ const exists = async (path) => {
13
+ try {
14
+ await access(path)
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ export const resolveConfigRoot = ({ packaged = false, override, env = process.env } = {}) => {
22
+ const requested = override ?? env.OPEN_SCIENCE_CONFIG_ROOT ?? env.OPEN_SCIENCE_STORAGE_ROOT
23
+ if (requested) {
24
+ if (!isAbsolute(requested)) throw new Error('The config root must be an absolute path.')
25
+ return normalize(requested)
26
+ }
27
+ return join(homedir(), packaged ? PROD_CONFIG_DIR : DEV_CONFIG_DIR)
28
+ }
29
+
30
+ export const candidateConfigRoots = ({ override, env = process.env } = {}) => {
31
+ if (override ?? env.OPEN_SCIENCE_CONFIG_ROOT ?? env.OPEN_SCIENCE_STORAGE_ROOT) {
32
+ return [resolveConfigRoot({ override, env })]
33
+ }
34
+ return [resolveConfigRoot({ packaged: false, env }), resolveConfigRoot({ packaged: true, env })]
35
+ }
36
+
37
+ export const readStateFromRoot = async (configRoot) => {
38
+ try {
39
+ const state = JSON.parse(await readFile(join(configRoot, STATE_FILE), 'utf8'))
40
+ if (
41
+ !Number.isInteger(state.pid) ||
42
+ !Number.isInteger(state.port) ||
43
+ typeof state.startedAt !== 'string'
44
+ ) {
45
+ return undefined
46
+ }
47
+ // The containing directory owns both the state and token. A stale or edited payload must not
48
+ // redirect credential reads or cleanup outside the enumerated candidate.
49
+ return { ...state, configRoot }
50
+ } catch {
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ export const findServiceState = async (options = {}) => {
56
+ for (const configRoot of candidateConfigRoots(options)) {
57
+ if (!(await exists(join(configRoot, STATE_FILE)))) continue
58
+ const state = await readStateFromRoot(configRoot)
59
+ if (state && (!options.accept || (await options.accept(state)))) return state
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ export const readWebToken = async (configRoot) =>
65
+ (await readFile(join(configRoot, TOKEN_FILE), 'utf8')).trim()