diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index b676af2..464777f 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -15,6 +15,7 @@ "automation/no-disable-validation": "error", "automation/no-silent-error-swallow": "error", "automation/prefer-effect-match": "error", + "automation/no-ambient-nondeterminism": "error", "anti-slop/no-reflect-apply": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/no-import-type-side-effects": "error", @@ -73,6 +74,15 @@ "automation/no-shadowed-standard-array-static": "off", "automation/no-silent-error-swallow": "off", "automation/prefer-effect-match": "off", + "automation/no-ambient-nondeterminism": "off", + }, + }, + { + // no-ambient-nondeterminism targets production seams. Tests deliberately use fixed or real + // time and node crypto, driven through explicit layers, so the rule is off for test files. + "files": ["packages/*/test/**"], + "rules": { + "automation/no-ambient-nondeterminism": "off", }, }, { diff --git a/packages/fold-agent/examples/ConfigAgent.ts b/packages/fold-agent/examples/ConfigAgent.ts index 1410726..90aa050 100644 --- a/packages/fold-agent/examples/ConfigAgent.ts +++ b/packages/fold-agent/examples/ConfigAgent.ts @@ -8,6 +8,7 @@ * -> writes ~/.fold/config.jsonc; edit providers/roles, export the referenced API key env var, re-run. * Then: bun packages/fold-agent/examples/ConfigAgent.ts "your prompt" */ +import { layerLiveIdFactory } from '@humanlayer/fold-core' import { Console, Effect } from 'effect' import { configInit, launchSession, loadFoldConfigOrNull } from '../src/index' @@ -28,7 +29,7 @@ const program = Effect.gen(function* () { const finished = yield* session.send(prompt) yield* Console.log(`\n[${finished.outcome}] ${finished.resultText ?? '(no text)'}`) -}).pipe(Effect.scoped) +}).pipe(Effect.provide(layerLiveIdFactory), Effect.scoped) Effect.runPromise(program).catch((error) => { console.error(error) diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index 4b8df65..7cb30a5 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,7 +8,7 @@ import { dirname } from 'node:path' import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_OPENCODE_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Effect, Match, Schema } from 'effect' +import { Clock, Effect, Match, Random, Schema } from 'effect' import { fileSystemFor } from '../Fs/DefaultFileSystem' import type { FoldConfig, ProviderKind } from './ConfigSchema' @@ -88,27 +88,32 @@ const validBaseUrl = (value: string): Effect.Effect => { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - const path = configPathFor(options) - const temporaryPath = `${path}.tmp-${process.pid}-${Date.now()}` - const text = `${JSON.stringify(config, null, '\t')}\n` - const writeDirect = fs.writeFileString(path, text, { mode: 0o600 }).pipe(Effect.andThen(fs.chmod(path, 0o600))) +): Effect.Effect => + Effect.gen(function* () { + const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const path = configPathFor(options) + // A unique temp path for the atomic write-rename. Clock/Random are the seams here (not Date.now/crypto), + // so a test can pin the temporary filename deterministically. + const now = yield* Clock.currentTimeMillis + const salt = (yield* Random.next).toString(36).slice(2) + const temporaryPath = `${path}.tmp-${process.pid}-${now}-${salt}` + const text = `${JSON.stringify(config, null, '\t')}\n` + const writeDirect = fs.writeFileString(path, text, { mode: 0o600 }).pipe(Effect.andThen(fs.chmod(path, 0o600))) - return fs.makeDirectory(dirname(path), { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(temporaryPath, text, { mode: 0o600 })), - Effect.andThen(fs.chmod(temporaryPath, 0o600)), - Effect.andThen(fs.rename(temporaryPath, path)), - Effect.andThen(fs.chmod(path, 0o600)), - // Some injected/sandbox filesystems do not implement rename. A mode-restricted direct write is - // still reasonable there; clean up the temporary file on either fallback outcome. - Effect.catch(() => writeDirect.pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore)))), - Effect.mapError( - (error) => - new ProviderConfigurationWriteError({ path, message: `could not write config: ${error.message}` }), - ), - ) -} + return yield* fs.makeDirectory(dirname(path), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(temporaryPath, text, { mode: 0o600 })), + Effect.andThen(fs.chmod(temporaryPath, 0o600)), + Effect.andThen(fs.rename(temporaryPath, path)), + Effect.andThen(fs.chmod(path, 0o600)), + // Some injected/sandbox filesystems do not implement rename. A mode-restricted direct write is + // still reasonable there; clean up the temporary file on either fallback outcome. + Effect.catch(() => writeDirect.pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore)))), + Effect.mapError( + (error) => + new ProviderConfigurationWriteError({ path, message: `could not write config: ${error.message}` }), + ), + ) + }) /** * Add or replace an Anthropic/OpenAI-compatible connection, preserving every other decoded config diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 5782f28..bf9ff93 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -33,6 +33,7 @@ import { type FoldModel, type FoldSession, type FoldTool, + type Ids, } from '@humanlayer/fold-core' import { Effect, Match, Schema, Semaphore, type Scope } from 'effect' @@ -480,7 +481,7 @@ const catalogFor = (options: LaunchSessionOptions): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index b8b0542..ac7695d 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -10,9 +10,9 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { SessionId, usageInputTotal } from '@humanlayer/fold-core' -import type { ActiveModel, LogEntry, FoldEventLog } from '@humanlayer/fold-core' -import { Effect, Exit, Match, Option, Schema, Stream } from 'effect' +import { SessionId, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' +import type { ActiveModel, LogEntry, FoldEventLog, Ids } from '@humanlayer/fold-core' +import { Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' @@ -155,10 +155,10 @@ const loadSessionIndex = (options?: SessionLayoutOptions): Effect.Effect => +): Effect.Effect<{ readonly sessionId: SessionId; readonly path: string; readonly log: FoldEventLog }, never, Ids> => Effect.gen(function* () { const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - const sessionId = SessionId.create() + const sessionId = yield* makeSessionId const directory = sessionsDirFor(options) yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie) @@ -375,7 +375,8 @@ export const deleteSession = ( const outputDirectory = toolOutputSessionDirFor({ sessionId, foldHome }) const outputExists = yield* fs.exists(outputDirectory).pipe(Effect.orDie) yield* fs.remove(logPath).pipe(Effect.orDie) - yield* appendSessionIndexRecord({ _tag: 'deleted', sessionId, ts: Date.now() }, options) + const ts = yield* Clock.currentTimeMillis + yield* appendSessionIndexRecord({ _tag: 'deleted', sessionId, ts }, options) if (!outputExists) return { deleted: true, outputRemoved: true } const outputRemoval = yield* Effect.exit(fs.remove(outputDirectory, { recursive: true })) diff --git a/packages/fold-agent/src/Session/ViewedChanges.ts b/packages/fold-agent/src/Session/ViewedChanges.ts index 96b7e92..125171f 100644 --- a/packages/fold-agent/src/Session/ViewedChanges.ts +++ b/packages/fold-agent/src/Session/ViewedChanges.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import { SessionId } from '@humanlayer/fold-core' -import { Effect, Option, Schema } from 'effect' +import { Clock, Effect, Option, Schema } from 'effect' import { fileSystemFor } from '../Fs/DefaultFileSystem' import { sessionsDirFor, type SessionLayoutOptions } from './SessionLayout' @@ -53,9 +53,12 @@ export const saveViewedPatchHash = ( ): Effect.Effect => { const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) const directory = sessionsDirFor(options) - const record = { sessionId, changeKey, patchHash, ts: Date.now() } - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(viewedChangesPath(options), `${JSON.stringify(record)}\n`, { flag: 'a' })), + return Effect.gen(function* () { + const ts = yield* Clock.currentTimeMillis + const record = { sessionId, changeKey, patchHash, ts } + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.writeFileString(viewedChangesPath(options), `${JSON.stringify(record)}\n`, { flag: 'a' }) + }).pipe( Effect.catch((error) => Effect.logWarning(`could not save viewed change for session ${sessionId}: ${error.message}`), ), diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index 743d1f2..8c580a6 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -11,7 +11,6 @@ * degrade to inline notes, never crash the run. Non-zero exit and timeout are typed model-visible * failures carrying the accumulated output; signal-killed commands are successes (pi semantics). */ -import { randomBytes } from 'node:crypto' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -29,7 +28,7 @@ import { utf8ByteLength, type FoldTool, } from '@humanlayer/fold-core' -import { type Context, Duration, Effect, Fiber, Layer, Option, Ref, Schema, Semaphore, Stream } from 'effect' +import { type Context, Duration, Effect, Fiber, Layer, Option, Random, Ref, Schema, Semaphore, Stream } from 'effect' import { ChildProcess, type ChildProcessSpawner } from 'effect/unstable/process' import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' @@ -286,9 +285,8 @@ export const bashTool = (options?: BashToolOptions): FoldTool => const currentToolCall = yield* CurrentToolCall const outputStore = options?.outputStore const spillRef = outputStore?.refFor(currentToolCall.toolCallId) - const spillPath = - spillRef?.path ?? - join(options?.spillDir ?? tmpdir(), `fold-bash-${randomBytes(8).toString('hex')}.log`) + const spillToken = `${(yield* Random.next).toString(36).slice(2)}${(yield* Random.next).toString(36).slice(2)}` + const spillPath = spillRef?.path ?? join(options?.spillDir ?? tmpdir(), `fold-bash-${spillToken}.log`) const accumulator = yield* makeAccumulator({ spillPath, writeSpill: (path, chunk) => diff --git a/packages/fold-agent/test/Mode/Launch.vi.test.ts b/packages/fold-agent/test/Mode/Launch.vi.test.ts index 60f7efd..820b4ef 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -8,14 +8,14 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { expect, it } from '@effect/vitest' -import { customModel, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' +import { customModel, layerLiveIdFactory, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' import { Effect, Stream } from 'effect' import { LanguageModel, type Response } from 'effect/unstable/ai' import { DEFAULT_CODING_PROMPT, defaultCodingMode, - launchSession, + launchSession as launchSessionRaw, mergeModelSelection, parseFoldConfig, resumeLatestSession, @@ -26,6 +26,11 @@ import { } from '../../src/index' import { tempDir } from '../TestHelpers' +// The Ids service is provided by the runtime in production (see cli.ts). Tests supply the same live +// factory so `launchSession` mints real ids; swap in `layerDeterministicIds` where determinism matters. +const launchSession = (options?: Parameters[0]) => + launchSessionRaw(options).pipe(Effect.provide(layerLiveIdFactory)) + const openAiActiveModel = (modelId: string): ActiveModel => ({ providerId: 'test', providerKind: 'openai-compatible', diff --git a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts index 4998905..bb4e20a 100644 --- a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts +++ b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts @@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, appendFil import { join } from 'node:path' import { expect, it } from '@effect/vitest' -import { customModel, defineAgent, SessionId, startSession } from '@humanlayer/fold-core' +import { customModel, defineAgent, layerLiveIdFactory, SessionId, startSession } from '@humanlayer/fold-core' import { Effect, Stream } from 'effect' import { LanguageModel } from 'effect/unstable/ai' @@ -18,7 +18,7 @@ import { deleteSession, listSessionLogs, listSessionSummaries, - prepareSessionLog, + prepareSessionLog as prepareSessionLogRaw, projectSlugFor, sessionLogPathFor, sessionsDirFor, @@ -26,6 +26,11 @@ import { } from '../../src/index' import { tempDir } from '../TestHelpers' +// The Ids service is provided by the runtime in production (see cli.ts). Tests supply the same live +// factory so `prepareSessionLog` mints real ids; swap in `layerDeterministicIds` where determinism matters. +const prepareSessionLog = (options?: Parameters[0]) => + prepareSessionLogRaw(options).pipe(Effect.provide(layerLiveIdFactory)) + it.effect('the project slug is a deterministic, filesystem-safe escape of the cwd', () => Effect.sync(() => { expect(projectSlugFor('/Users/kyle/projects/fold')).toBe('Users-kyle-projects-fold') diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index 0ce3a05..c2738f5 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -20,6 +20,7 @@ import { makeCodexAuthStore } from '@humanlayer/fold-codex' import type { ActiveModel, AgentFinishedLogEntry, + Ids, LogEntry, ModelCatalogEntry, SessionId, @@ -80,7 +81,9 @@ const launchOptions = (options: CliSessionOptions) => ({ }) /** Start fresh, resume the project's newest log, or adopt one exact session id. */ -const openSessionFor = (options: CliSessionOptions): Effect.Effect => { +const openSessionFor = ( + options: CliSessionOptions, +): Effect.Effect => { if (options.resume === undefined) return launchSession(launchOptions(options)) return options.resume._tag === 'latest' @@ -88,7 +91,7 @@ const openSessionFor = (options: CliSessionOptions): Effect.Effect => +const openSession = (options: CliSessionOptions): Effect.Effect => Effect.gen(function* () { const session = yield* openSessionFor(options) const logPath = sessionLogPathFor(session.sessionId, { @@ -279,7 +282,7 @@ const forkStartupEnsures = (options: CliSessionOptions, renderer: OutputRenderer export const runPrompt = ( options: PromptRunOptions, renderer: OutputRenderer, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { yield* bootstrapForRun(options) const opened = yield* openSession(options) diff --git a/packages/fold-cli/src/cli.ts b/packages/fold-cli/src/cli.ts index ca4ab84..f2476fd 100644 --- a/packages/fold-cli/src/cli.ts +++ b/packages/fold-cli/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import * as NodeRuntime from '@effect/platform-node/NodeRuntime' import * as NodeServices from '@effect/platform-node/NodeServices' -import { Effect } from 'effect' +import { layerLiveIdFactory } from '@humanlayer/fold-core' +import { Effect, Layer } from 'effect' import { main } from './Commands' -main.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain) +main.pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, layerLiveIdFactory)), NodeRuntime.runMain) diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index b39a405..200d67b 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -12,7 +12,7 @@ import { type SessionToResumeNotFoundError, type FoldConfig, } from '@humanlayer/fold-agent' -import { lookupCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' +import { layerLiveIdFactory, lookupCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' import { Cause, Duration, Effect, Match, Option, Scope } from 'effect' import { createSignal, type Accessor } from 'solid-js' @@ -200,7 +200,7 @@ export const makeTuiSessionWorkspace = (options: { host .register( acquire( - initialSession(options.tui), + initialSession(options.tui).pipe(Effect.provide(layerLiveIdFactory)), { cwd: options.tui.cwd, profile: options.tui.profile ?? 'default', @@ -246,7 +246,15 @@ export const makeTuiSessionWorkspace = (options: { } cwds.add(metadata.cwd) return reserve( - host.register(acquire(launchSession(launchOptions(next)), metadata, true)).pipe(Effect.flatMap(finish)), + host + .register( + acquire( + launchSession(launchOptions(next)).pipe(Effect.provide(layerLiveIdFactory)), + metadata, + true, + ), + ) + .pipe(Effect.flatMap(finish)), ) } const remove = (sessionId: SessionId) => diff --git a/packages/fold-codex/src/CodexAuth.ts b/packages/fold-codex/src/CodexAuth.ts index a38a259..6538496 100644 --- a/packages/fold-codex/src/CodexAuth.ts +++ b/packages/fold-codex/src/CodexAuth.ts @@ -11,6 +11,7 @@ */ import { arch, platform, release } from 'node:os' +import * as NodeCrypto from '@effect/platform-node/NodeCrypto' import { Clock, Context, Effect, Option, Semaphore } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/unstable/http' @@ -139,7 +140,7 @@ export const makeCodexAuth = Effect.fnUntraced(function* (options?: MakeCodexAut client: issuerClient, onUrl: options?.onBrowserUrl ?? defaultOnBrowserUrl, ...options?.browser, - }), + }).pipe(Effect.provide(NodeCrypto.layer)), ), ) .pipe(Effect.withSpan('fold.codexAuth.authenticateBrowser')), diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index b3c93db..522395d 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -8,7 +8,7 @@ import { createServer } from 'node:http' import type { Server } from 'node:http' -import { Clock, Deferred, Duration, Effect, Encoding, Option, Result, Schedule, Schema } from 'effect' +import { Clock, Crypto, Deferred, Duration, Effect, Encoding, Option, Result, Schedule, Schema } from 'effect' import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' import { CodexTokenData } from './AuthStore' @@ -315,15 +315,12 @@ export const runDeviceFlow = Effect.fn('fold.codexAuth.deviceFlow')(function* (o const PKCE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' -const generateRandomString = (length: number): string => { - const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes) +const pkceString = (bytes: Uint8Array): string => + Array.from(bytes) .map((byte) => PKCE_CHARSET[byte % PKCE_CHARSET.length]) .join('') -} -const base64UrlEncode = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer) +const base64UrlEncode = (bytes: Uint8Array): string => { const binary = String.fromCharCode(...bytes) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } @@ -335,13 +332,17 @@ export type PkceCodes = { } /** Generate a PKCE verifier (43 chars over the unreserved set) and its S256 challenge. */ -export const generatePkce: Effect.Effect = Effect.promise(async () => { - const verifier = generateRandomString(43) - const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) +export const generatePkce: Effect.Effect = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto + const verifier = pkceString(yield* crypto.randomBytes(43).pipe(Effect.orDie)) + const hash = yield* crypto.digest('SHA-256', new TextEncoder().encode(verifier)).pipe(Effect.orDie) return { verifier, challenge: base64UrlEncode(hash) } }) -const generateState = (): string => base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +const generateState: Effect.Effect = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto + return base64UrlEncode(yield* crypto.randomBytes(32).pipe(Effect.orDie)) +}) /** The authorization URL a browser-flow user opens (agentlayer's exact parameter set). */ export const buildAuthorizeUrl = (redirectUri: string, pkce: PkceCodes, state: string): string => { @@ -404,7 +405,7 @@ export const runBrowserFlow = Effect.fn('fold.codexAuth.browserFlow')(function* const redirectUri = `http://${hostname}:${port}/auth/callback` const pkce = yield* generatePkce - const state = generateState() + const state = yield* generateState const code = yield* Effect.scoped( Effect.gen(function* () { diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index 60e2a77..b62fcaf 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { Server } from 'node:http' -import { Clock, Deferred, Duration, Effect, Schedule, Schema } from 'effect' +import { Clock, Crypto, Deferred, Duration, Effect, Schedule, Schema } from 'effect' import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' import { XaiTokenData } from './AuthStore' @@ -194,16 +194,18 @@ export const runXaiDeviceFlow = Effect.fn('fold.xaiAuth.deviceFlow')(function* ( }) const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' -const random = (length: number): string => - Array.from(crypto.getRandomValues(new Uint8Array(length))) +const pkceString = (bytes: Uint8Array): string => + Array.from(bytes) .map((byte) => CHARS[byte % CHARS.length]) .join('') -const base64Url = (buffer: ArrayBuffer): string => Buffer.from(buffer).toString('base64url') +const base64Url = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64url') export type XaiPkce = { readonly verifier: string; readonly challenge: string } -export const generateXaiPkce: Effect.Effect = Effect.promise(async () => { - const verifier = random(64) - return { verifier, challenge: base64Url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))) } +export const generateXaiPkce: Effect.Effect = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto + const verifier = pkceString(yield* crypto.randomBytes(64).pipe(Effect.orDie)) + const challenge = base64Url(yield* crypto.digest('SHA-256', new TextEncoder().encode(verifier)).pipe(Effect.orDie)) + return { verifier, challenge } }) /** Build xAI's registered Grok CLI authorization URL. */ @@ -231,9 +233,10 @@ export type XaiBrowserFlowOptions = { /** Run browser PKCE on xAI's fixed registered 127.0.0.1:56121 callback. */ export const runXaiBrowserFlow = Effect.fn('fold.xaiAuth.browserFlow')(function* (options: XaiBrowserFlowOptions) { + const crypto = yield* Crypto.Crypto const pkce = yield* generateXaiPkce - const state = base64Url(crypto.getRandomValues(new Uint8Array(32)).buffer) - const nonce = base64Url(crypto.getRandomValues(new Uint8Array(32)).buffer) + const state = base64Url(yield* crypto.randomBytes(32).pipe(Effect.orDie)) + const nonce = base64Url(yield* crypto.randomBytes(32).pipe(Effect.orDie)) const code = yield* Effect.scoped( Effect.gen(function* () { const callback = yield* Deferred.make() diff --git a/packages/fold-xai/src/XaiAuth.ts b/packages/fold-xai/src/XaiAuth.ts index b88198e..45dda71 100644 --- a/packages/fold-xai/src/XaiAuth.ts +++ b/packages/fold-xai/src/XaiAuth.ts @@ -1,4 +1,5 @@ /** Persistent, single-flight xAI OAuth credential service and authenticated HTTP decorator. */ +import * as NodeCrypto from '@effect/platform-node/NodeCrypto' import { Clock, Context, Effect, Option, Semaphore } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/unstable/http' @@ -74,7 +75,13 @@ export const makeXaiAuth = Effect.fnUntraced(function* (options?: MakeXaiAuthOpt .pipe(Effect.withSpan('fold.xaiAuth.authenticateDevice')), authenticateBrowser: semaphore .withPermit( - run(runXaiBrowserFlow({ client, onUrl: options?.onBrowserUrl ?? browserPrompt, ...options?.browser })), + run( + runXaiBrowserFlow({ + client, + onUrl: options?.onBrowserUrl ?? browserPrompt, + ...options?.browser, + }).pipe(Effect.provide(NodeCrypto.layer)), + ), ) .pipe(Effect.withSpan('fold.xaiAuth.authenticateBrowser')), logout: semaphore diff --git a/tools/oxlint/automation/index.ts b/tools/oxlint/automation/index.ts index d81b991..f028c6e 100644 --- a/tools/oxlint/automation/index.ts +++ b/tools/oxlint/automation/index.ts @@ -1,5 +1,6 @@ import { eslintCompatPlugin } from '@oxlint/plugins' +import noAmbientNondeterminism from './rules/no-ambient-nondeterminism.ts' import noDisableValidation from './rules/no-disable-validation.ts' import noShadowedStandardArrayStatic from './rules/no-shadowed-standard-array-static.ts' import noSilentErrorSwallow from './rules/no-silent-error-swallow.ts' @@ -8,6 +9,7 @@ import preferEffectMatch from './rules/prefer-effect-match.ts' export default eslintCompatPlugin({ meta: { name: 'automation' }, rules: { + 'no-ambient-nondeterminism': noAmbientNondeterminism, 'no-disable-validation': noDisableValidation, 'no-shadowed-standard-array-static': noShadowedStandardArrayStatic, 'no-silent-error-swallow': noSilentErrorSwallow, diff --git a/tools/oxlint/automation/rules/no-ambient-nondeterminism.ts b/tools/oxlint/automation/rules/no-ambient-nondeterminism.ts new file mode 100644 index 0000000..2712ba3 --- /dev/null +++ b/tools/oxlint/automation/rules/no-ambient-nondeterminism.ts @@ -0,0 +1,99 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +// Vendored and adapted from typeonce-dev/ai-automation (rules/oxlint/src/rules/no-ambient-nondeterminism.ts). +// Adaptations: dropped the upstream's `.tsx` allow-list option (TUI/theme and tests are excluded through +// `.oxlintrc.jsonc` overrides instead) and mapped the inline messages onto `messageId`s. The scope analysis +// is preserved on purpose: it is what lets a local binding such as `const crypto = yield* Crypto.Crypto` pass +// while still catching the ambient `crypto` / `Date` / `Math` globals. + +const memberPropertyName = (node: ESTree.MemberExpression): string | undefined => + node.property.type === 'Identifier' + ? node.property.name + : node.property.type === 'Literal' && typeof node.property.value === 'string' + ? node.property.value + : undefined + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow ambient randomness and time in favor of Effect Clock, Random, and Crypto capabilities.', + }, + messages: { + ambientDate: 'Do not read the current time from ambient Date. Use Effect Clock or DateTime capabilities.', + ambientCrypto: "Do not use ambient crypto. Use Effect's Crypto capability instead.", + ambientMathRandom: "Do not use ambient Math.random. Use Effect's Random capability instead.", + }, + }, + createOnce(context) { + const isGlobalIdentifier = (name: string, node: ESTree.Identifier): boolean => { + if (node.name !== name) return false + let scope: ReturnType | null = context.sourceCode.getScope(node) + while (scope !== null) { + const variable = scope.set.get(name) + if (variable !== undefined) return variable.defs.length === 0 + scope = scope.upper + } + return true + } + + const isGlobalThisMember = (name: string, node: ESTree.MemberExpression): boolean => + node.object.type === 'Identifier' && + isGlobalIdentifier('globalThis', node.object) && + memberPropertyName(node) === name + + const isGlobalObject = (name: string, node: ESTree.Node): boolean => + (node.type === 'Identifier' && isGlobalIdentifier(name, node)) || + (node.type === 'MemberExpression' && isGlobalThisMember(name, node)) + + return { + CallExpression(node) { + if ( + node.arguments.length !== 0 || + node.callee.type !== 'Identifier' || + !isGlobalIdentifier('Date', node.callee) + ) { + return + } + context.report({ node, messageId: 'ambientDate' }) + }, + Identifier(node) { + if ( + node.parent?.type === 'MemberExpression' && + node.parent.property === node && + node.parent.computed !== true + ) { + return + } + if (!isGlobalIdentifier('crypto', node)) return + context.report({ node, messageId: 'ambientCrypto' }) + }, + MemberExpression(node) { + if (isGlobalThisMember('crypto', node)) { + context.report({ node, messageId: 'ambientCrypto' }) + return + } + const propertyName = memberPropertyName(node) + if (propertyName === 'random' && isGlobalObject('Math', node.object)) { + context.report({ node, messageId: 'ambientMathRandom' }) + return + } + if (propertyName === 'now' && isGlobalObject('Date', node.object)) { + context.report({ node, messageId: 'ambientDate' }) + } + }, + NewExpression(node) { + if ( + node.arguments.length !== 0 || + node.callee.type !== 'Identifier' || + !isGlobalIdentifier('Date', node.callee) + ) { + return + } + context.report({ node, messageId: 'ambientDate' }) + }, + } + }, +})