Skip to content

Windows: uncaught EPERM creating .gjc under a non-writable cwd kills every session start #4568

Description

@furthur1011

Summary

On Windows, gjc dies with an uncaught EPERM before producing any output whenever its working directory is not writable. The most common way to hit this is a console launched without a "Start in" directory, which defaults to C:\Windows\system32.

[Uncaught Exception] Error: EPERM: operation not permitted, mkdir 'C:\Windows\system32\.gjc'
    at mkdirSync (unknown)
    at flushState (.../modes/shared/agent-wire/workflow-gate-broker.ts:1044:3)
    at commit (.../workflow-gate-broker.ts:1080:8)
    at beginRuntimeInstance (.../workflow-gate-broker.ts:1128:8)
    at new WorkflowGateBroker (.../workflow-gate-broker.ts:1237:14)
    at new BrokerWorkflowGateEmitter (.../workflow-gate-broker.ts:159:22)
    at #constructWorkflowGateEmitter (.../session/agent-session.ts:8080:14)
    at #bindWorkflowGateEmitter (.../session/agent-session.ts:8110:41)
    at new AgentSession (.../session/agent-session.ts:2870:8)
    at createAgentSession (.../sdk/session.ts:3000:17)
{"code":"EPERM","path":"C:\\Windows\\system32\\.gjc","syscall":"mkdir","errno":-1}

Reproduction

Any non-elevated shell, with a cwd the user cannot write to:

cd C:\Windows\System32
gjc -p "say hi"

100% reproducible on 0.13.2. The session never starts, and the error arrives as a raw ErrnoException rather than anything actionable.

Root cause

FileGateStore's constructor already documents the invariant this violates:

// Load eagerly so a corrupt store fails closed at construction, but defer
// directory creation to the first write so constructing a store under a
// non-writable cwd (e.g. a session that never emits a gate) never throws.

That deferral (added in #2078) is defeated by WorkflowGateBroker's constructor, which writes immediately:

constructor(runId, store, hooks = {}, instanceId = crypto.randomUUID()) {
    this.store.beginRuntimeInstance(this.instanceId);
}

On a fresh store state.runtimeInstanceId is undefined, so beginRuntimeInstance always falls through to commit(next)flushStatemkdirSync. "The first write" is therefore always construction, and the store's gate-state path is cwd-derived (sessionStateDir(getCwd(), sessionId) at agent-session.ts:8077). So every session start mkdirs under cwd, and an unwritable cwd is fatal.

Two things compound it:

  1. mkdirSync is the one statement in flushState sitting outside the try/catch that turns every other write failure into a typed GateStoreWriteError, so this failure escapes the store abstraction entirely.
  2. Nothing is actually being persisted at that moment. A store with no gates and no counters has nothing a later process could recover or quarantine — the write exists only to stamp a runtime instance id.

Suggested fix

Restore the documented invariant: skip the flush in beginRuntimeInstance when the state holds no gates and no counters, letting the instance id ride along with the first real mutation, and move mkdirSync inside the existing try so any genuine failure surfaces as GateStoreWriteError.

I have this working with regression tests and will open a PR shortly.

Note on a second, non-fatal path

With the above fixed, gjc -p completes successfully from system32 (exit 0). A telemetry callback still attempts the same mkdir, but that path already catches and swallows it:

[pi-agent] onChatUsage threw; swallowing telemetry callback failure  EPERM: operation not permitted, mkdir 'C:\Windows\System32\.gjc'

Non-blocking, and out of scope for this issue — flagging it in case it is worth the same lazy treatment.

Environment

  • @gajae-code/coding-agent 0.13.2 (bun global install)
  • Bun 1.3.14
  • Windows 11 Pro 26200

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions