From 762ef6485817621cbbafd744f62be8a5c4d27612 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 18 Aug 2026 19:48:24 -0700 Subject: [PATCH 1/5] refactor: route session time and id minting through Effect seams Ambient Date.now() and SessionId.create() bypassed the Clock and Ids service seams the rest of Fold already uses, so session-layout writes and id minting could not be pinned in tests. - The SessionLayout deletion tombstone and ViewedChanges now read Clock.currentTimeMillis instead of Date.now(). - prepareSessionLog mints via the Ids service; the requirement threads up the launch path and the app runtime provides layerLiveIdFactory (cli.ts). The TUI acquire sites and the ConfigAgent example provide the same live layer. - The Launch and SessionLayout tests shadow the launch/prepare helpers with the live id layer, preserving behavior while leaving DeterministicIds as a seam. Behavior-preserving prep for the no-ambient-nondeterminism rule. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0179a-5c6f-7ab7-8e1e-92b5a54ffad3 --- packages/fold-agent/examples/ConfigAgent.ts | 3 ++- packages/fold-agent/src/Mode/Launch.ts | 3 ++- packages/fold-agent/src/Session/SessionLayout.ts | 13 +++++++------ packages/fold-agent/src/Session/ViewedChanges.ts | 11 +++++++---- packages/fold-agent/test/Mode/Launch.vi.test.ts | 9 +++++++-- .../test/Session/SessionLayout.vi.test.ts | 9 +++++++-- packages/fold-cli/src/Run.ts | 9 ++++++--- packages/fold-cli/src/cli.ts | 3 ++- packages/fold-cli/src/tui/TuiSessionWorkspace.ts | 14 +++++++++++--- 9 files changed, 51 insertions(+), 23 deletions(-) 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/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/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..c8bbb76 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 { layerLiveIdFactory } from '@humanlayer/fold-core' import { Effect } from 'effect' import { main } from './Commands' -main.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain) +main.pipe(Effect.provide(layerLiveIdFactory), Effect.provide(NodeServices.layer), 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) => From 28412509d5c595571205baa3c9c5af7d762e619b Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 18 Aug 2026 19:56:32 -0700 Subject: [PATCH 2/5] refactor: seam temp-path randomness through Clock and Random writeConfig's atomic-rename temp path and BashTool's spill filename used Date.now() and node crypto randomBytes directly. Route them through the Clock and Random default services so tests can pin the generated names. Runtime behavior is unchanged - Random defaults to Math.random, which is fine for filename uniqueness (these are not security tokens). Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0179a-5c6f-7ab7-8e1e-92b5a54ffad3 --- .../fold-agent/src/Config/ProviderConfig.ts | 47 ++++++++++--------- packages/fold-agent/src/Tools/BashTool.ts | 8 ++-- 2 files changed, 29 insertions(+), 26 deletions(-) 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/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) => From 77a6780e61f04e35f519d289beac220e6ba90804 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 18 Aug 2026 20:09:40 -0700 Subject: [PATCH 3/5] refactor: route OAuth CSPRNG through the Effect Crypto service PKCE verifier/state/nonce and the S256 challenge digest used the ambient crypto.getRandomValues / crypto.subtle globals, so their values could not be pinned in tests. Route them through Effect's Crypto service (randomBytes + digest); the live NodeCrypto layer is node:crypto - a real CSPRNG - and is already provided app-wide via NodeServices at cli.ts. - generatePkce/generateState (codex) and generateXaiPkce + state/nonce (xai) now yield Crypto; crypto failures orDie, matching the prior Effect.promise defect semantics, so only the R channel changes. - The browser-flow call sites provide NodeCrypto.layer locally, keeping the auth service interfaces at R = never (no consumer or TUI ripple). Security is unchanged (same node CSPRNG). Tests can now provide a deterministic Crypto layer to pin PKCE values. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0179a-5c6f-7ab7-8e1e-92b5a54ffad3 --- packages/fold-codex/src/CodexAuth.ts | 3 ++- packages/fold-codex/src/OAuthFlows.ts | 25 +++++++++++++------------ packages/fold-xai/src/OAuthFlows.ts | 21 ++++++++++++--------- packages/fold-xai/src/XaiAuth.ts | 9 ++++++++- 4 files changed, 35 insertions(+), 23 deletions(-) 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 From d319641571b38da0469ab3795137e187db1dff98 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 19 Aug 2026 10:47:43 -0700 Subject: [PATCH 4/5] chore: enforce automation/no-ambient-nondeterminism Vendor the TypeOnce no-ambient-nondeterminism rule into tools/oxlint/automation (adapted: dropped the .tsx allow-list option, mapped messages to messageIds; the scope analysis is kept so local bindings like `const crypto = yield* Crypto.Crypto` pass while the ambient globals are caught) and enforce it as an error. Ambient Date.now()/new Date()/Math.random() and the global crypto object must go through Effect's Clock, Random, and Crypto capabilities. TUI/theme and test files are excluded via overrides. Zero in-scope findings remain after the preceding seam refactors (session time/ids, temp paths, OAuth CSPRNG). Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0179a-5c6f-7ab7-8e1e-92b5a54ffad3 --- .oxlintrc.jsonc | 10 ++ tools/oxlint/automation/index.ts | 2 + .../rules/no-ambient-nondeterminism.ts | 99 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 tools/oxlint/automation/rules/no-ambient-nondeterminism.ts 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/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' }) + }, + } + }, +}) From d5ccae1056135d4445723283b35a22dfa54d56a8 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Wed, 19 Aug 2026 11:18:38 -0700 Subject: [PATCH 5/5] fix: merge cli runtime layers into a single Effect.provide Chaining Effect.provide(layerLiveIdFactory) after Effect.provide(NodeServices.layer) tripped the effect tsgolint multipleEffectProvide check (TS377033), failing typecheck. Merge both layers with Layer.mergeAll and provide once. Co-Authored-By: Claude Opus 4.8 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0179a-5c6f-7ab7-8e1e-92b5a54ffad3 --- packages/fold-cli/src/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fold-cli/src/cli.ts b/packages/fold-cli/src/cli.ts index c8bbb76..f2476fd 100644 --- a/packages/fold-cli/src/cli.ts +++ b/packages/fold-cli/src/cli.ts @@ -2,8 +2,8 @@ import * as NodeRuntime from '@effect/platform-node/NodeRuntime' import * as NodeServices from '@effect/platform-node/NodeServices' import { layerLiveIdFactory } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Effect, Layer } from 'effect' import { main } from './Commands' -main.pipe(Effect.provide(layerLiveIdFactory), Effect.provide(NodeServices.layer), NodeRuntime.runMain) +main.pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, layerLiveIdFactory)), NodeRuntime.runMain)