Repository navigation
feat: keep one session per environment instead of a shared session.json (#179) - #186
Merged
Merged
Conversation
…on (#179) Each environment in config.json now caches its session at <configDir>/sessions/<env>.json, recording the env, baseUrl and username it was minted for; a cached session recorded for anything else is ignored. Credentials from <CLI>_URL/_USER/_PASS get their own file. - config switch no longer deletes a session, so switching away and back doesn't log you out, and one environment's session is never sent to another's host (project mode used to delete the global-mode file and keep the project one). - New config clear-session [name] [--all]. - CLI routine run reuses the cached session; cli.runRoutine() and runRoutine() still clear first, scoped to the resolved environment. - saveEnvironment() clears the saved environment's session, so setup, config import, config update-password and MCP setup never leave the old credentials cached. - sessions/ is created 0700 with a .gitignore containing '*'; session files are written 0600. - The legacy session.json is no longer read or written. - new SessionManager(cliName, path?) is unchanged for deep importers.
…sts (#179) os.homedir() reads USERPROFILE on Windows, so the default-mode fixture read the runner's real profile and found no config.
9 of 19 tasks
garretpremo
commented
Oct 19, 2026
garretpremo
left a comment
Contributor
Author
There was a problem hiding this comment.
🤖 Automated review by claude — generated by the
review-issueskill. Treat as advisory; a human still owns the merge decision.
Looks good. Every acceptance criterion from #179's triage is met, and the maintainer's settled decisions are implemented as described.
- Every core path goes through
sessionManagerFor(currentEnvironment()):- generated commands
- custom commands and dispatchers (
resolveSession/saveSession/refreshSession) generate- the refresh-on-401 retry
cli.runRoutine()
saveEnvironment()clears the same directory thatcreateCliwrites to. That holds forsetup,config import,config update-password, the MCPsetuptool and the shared binary in both modes.- The env-name encoding is injective and stays inside
sessions/. new SessionManager(cliName, path?)andsrc/index.tsare unchanged.
What was checked:
- CI is green at
d3569d7. bun testpasses, apart from the known/tmp-locationpaths.test.tsfailure.tscreports the same error set asdev, and lint has 0 errors.config clear-session(active, named, unknown,--all, env-var credentials) was run against a real project-mode.apijack/: output and exit codes match the PR body, andsessions/is0700with0600files.
Non-blocking observations
- A password edited by hand in
config.jsonkeeps the old Basic session untilconfig clear-session.- The guard in
src/session.tschecks baseUrl and username, not the password, andBasicAuthStrategy.restorereturns the cachedAuthorizationheader as-is. - This predates the PR. Before it, though, default-mode
config switchandroutine runreset the session as a side effect, and both are gone now. - CLIs without
knownSiteshave noconfig update-password, so editing the file is a plausible way to change a password. The publicupdateEnvironmentField(cli, 'password', …)doesn't clear the session either. - Suggest one line in the CLAUDE.md "Sessions" section (and maybe the setup-api troubleshooting list): run
config clear-sessionafter editing credentials by hand.
- The guard in
- On case-insensitive filesystems,
Devanddevshare one file.- The recorded-env guard prevents a leak, as the PR notes.
- But switching between the two re-authenticates every time, and
saveEnvironment('dev')/config clear-session devalso clearDev's session. - It's rare and acknowledged. If it ever matters, escaping uppercase letters or appending a short hash of the raw name would separate them.
- Test gaps:
- The
--allcase intests/per-env-sessions.test.tschecks that sessions are minted again, but not thatsessions/itself is removed, which the acceptance criterion states. I confirmed by probe that it is removed; anexistsSync(...sessions) === falseassertion would pin it. - Generated API commands and
generateare covered only by reading the code. They share step 5'ssessionMgr.
- The
- Branch is behind
dev: #185 (882fa22) has landed since the branch point. It merges cleanly, andbun testand lint pass on the merge result.
Nitpicks
mkdirSync(dir, { mode: 0o700 })andwriteFileSync(..., { mode: 0o600 })only apply when the file or directory is created. Asessions/directory that already exists with looser permissions isn't tightened.- Speculative: percent-encoding expands non-ASCII names about 9×, so a name of roughly 28 or more CJK characters exceeds the 255-byte filename limit, and the first save would throw
ENAMETOOLONG. A hashed fallback above a length cap would cover it.
garretpremo
commented
Oct 19, 2026
garretpremo
left a comment
Contributor
Author
There was a problem hiding this comment.
🤖 Final review by claude — generated by the
final-reviewskill. CI is green, no blockers, soak window elapsed. Marking approved and merging.
Approved.
Outstanding observations from first-pass review
- A password edited by hand in
config.jsonkeeps the old Basic session untilconfig clear-session.- The guard in
src/session.tschecks baseUrl and username, not the password, andBasicAuthStrategy.restorereturns the cachedAuthorizationheader as-is. - This predates the PR. Before it, though, default-mode
config switchandroutine runreset the session as a side effect, and both are gone now. - CLIs without
knownSiteshave noconfig update-password, so editing the file is a plausible way to change a password. The publicupdateEnvironmentField(cli, 'password', …)doesn't clear the session either. - Suggest one line in the CLAUDE.md "Sessions" section (and maybe the setup-api troubleshooting list): run
config clear-sessionafter editing credentials by hand.
- The guard in
- On case-insensitive filesystems,
Devanddevshare one file.- The recorded-env guard prevents a leak, as the PR notes.
- But switching between the two re-authenticates every time, and
saveEnvironment('dev')/config clear-session devalso clearDev's session. - It's rare and acknowledged. If it ever matters, escaping uppercase letters or appending a short hash of the raw name would separate them.
- Test gaps:
- The
--allcase intests/per-env-sessions.test.tschecks that sessions are minted again, but not thatsessions/itself is removed, which the acceptance criterion states. I confirmed by probe that it is removed; anexistsSync(...sessions) === falseassertion would pin it. - Generated API commands and
generateare covered only by reading the code. They share step 5'ssessionMgr.
- The
- Branch is behind
dev: #185 (882fa22) has landed since the branch point. It merges cleanly, andbun testand lint pass on the merge result.
This was referenced Oct 19, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #179
Summary
Every environment in
config.jsonnow keeps its own cached session at<configDir>/sessions/<env>.json, replacing the single sharedsession.json. Switching environments no longer logs you out, a session is never sent to another environment's host, and a script driving one environment no longer clobbers the session of someone working in another.This also fixes the project-mode bug found in triage.
config switchand CLIroutine runbuiltnew SessionManager(cliName), which always points at~/.<cli>/session.json. In project mode they deleted the global-mode session and left the project's session in place. Afterconfig switch b, environmenta's Basic credentials went tob's host.It implements the triage decisions on the issue:
routine runreuses the session; programmaticrunRoutinestill starts fresh.config clear-session [name] [--all].session.jsonis ignored.It also includes the maintainer's later rulings:
sessions/.gitignore(from #182's triage), and clearing an environment's session when its credentials are saved plus0700/0600permissions (from the self-review).Release note: log in once per environment after upgrading. The old
session.jsonis no longer read and is safe to delete. The new.apijack/sessions/(or~/.<cli>/sessions/) directory ignores itself in git (.gitignorecontaining*), so session tokens stay out of git even in projects that only ignore.apijack/session.json.Behavior changes
config switchno longer deletes the cached session. Each environment keeps its own.routine run(default,--json,-s/--quiet) no longer clears the session before running; it reuses the cached one.cli.runRoutine()/runRoutine()still clear first, but only the resolved environment's file.setup/login,config import,config update-passwordand the MCPsetuptool, which all go throughsaveEnvironment(). Other environments' sessions are untouched. Re-runningsetupwith unchanged credentials costs one extra login.config clear-session [name] [--all].<configDir>/sessions/<env>.json, wrapped as{ env, baseUrl, username, session }.sessions/is created0700and session files are written0600.<configDir>/session.jsonis never read, migrated or deleted.What changes
src/session.tsSessionManager.forEnvironment(configDir, { env, baseUrl, username })stores<configDir>/sessions/<encoded-env>.jsonas{ env, baseUrl, username, session }.resolve()ignores a cached file recorded for a different env, baseUrl or username, and logs in again.AuthSession.*and Windows device names (con,nul,com1, ...) escaped too. The encoding is reversible, so distinct names get distinct filenames and none can escapesessions/. On a case-insensitive filesystemDevanddevshare a file, but the recorded env keeps either from using the other's session.env: null(credentials from<CLI>_URL/_USER/_PASS) maps to@env-vars.json. Encoded names always escape@, so no config environment can claim that file.sessions/is created0700with a.gitignorecontaining*, and session files are written0600.SessionManager.clear(configDir, env)andSessionManager.clearAll(configDir), each reporting whether anything was removed.new SessionManager(cliName, path?)is unchanged (single raw file, no guard, default permissions), for consumers that deep-import it.src/config.tsresolveEnvironment(cliName, opts)returns{ name, auth }, wherenameis null for env-var credentials.resolveAuth()delegates to it, with unchanged behavior.saveEnvironment()clears the saved environment's session after writingconfig.json.src/cli-builder.tscreateClihas a singlecurrentEnvironment(), which selects the credentials and the session file, plussessionManagerFor(). Bothrun()and_buildRoutineRuntime()use them.getActiveEnvConfig()still reads sessionAuth / refreshOn from the active environment on its own. That is unchanged here, but Global --config <name> option to run a command against any configured environment #180's--confighas to drive that lookup as well ascurrentEnvironment.cli.runRoutine()/runRoutine()keep clearing before a run, but only the resolved environment's file.{ configDir, currentEnvironment }forconfig clear-session.config switch/config clear-sessionconfig switchdeletes nothing; theinvalidateSessiondep is gone fromconfigSwitchAction.config clear-session [name] [--all](src/commands/config/clear-session/):<CLI>_URL/_USER/_PASSare set).config switch.--allremovessessions/.--allwith a name is rejected (exit 1).configbecause Commander 14 throws on a duplicate top-level name, and downstream CLIs may register their ownclear-session.routine run(CLI)routine/register.tsno longer constructs aSessionManager, and theinvalidateSessiondep is gone fromroutineRunAction.Docs
config clear-sessionin the built-in commands table (also in AGENTS.md / GEMINI.md).skills/setup-api/SKILL.md: mentionsconfig clear-session.test-next-releaseteardown: removessessions/petstore.json.Downstream consumers
Consumers that deep-import apijack internals (e.g.
new SessionManager(cliName, path)) or readsession.jsondirectly need a coordinated update when they move to this release. This isn't semver-covered, because these aresrc/deep imports rather than@apijack/coreexports:new SessionManager(cliName, path?)constructor keeps working unchanged, but it logs in separately from core until moved toSessionManager.forEnvironment.session.jsonshould switch to the built-inconfig clear-sessionand torequiresAuth+ctx.session.Acceptance criteria
<configDir>/sessions/<encoded-env>.json. A name containing/,\or..can't escapesessions/, and filenames are valid on Windows.ctx.resolveSession/saveSession/refreshSession),generate,cli.runRoutine()/runRoutine().src/commands/config/register.tsandsrc/commands/routine/register.tsno longer construct aSessionManager.~/.<cli>/session.json.config switchclears no session. Switchinga→b→areusesa's cached session.a's session is never sent tob's host.routine run(default,--json,-s/--quiet) no longer clears the session.cli.runRoutine()/runRoutine()still clear before running, only the resolved environment's file.config clear-sessionclears the active environment,config clear-session <name>clears that one (unknown name exits 1 with the list), and--allremovessessions/.--allplus a name is rejected.config update-password, the old cached Basic session is not reused.sessions/writessessions/.gitignorecontaining*, so no session file shows ingit status.sessions/is0700and session files are0600(not asserted on win32).<configDir>/session.jsonis neither read nor deleted.new SessionManager(cliName, path?)behaves as before.CliContext,AuthSession,AuthStrategyare unchanged.Test plan
bun test: 1124 pass / 0 failbun run lint: 0 errors (118 warnings, same as dev)bunx tsc --noEmit -p .: 16 errors, same set as dev. The one insrc/commands/config/register.tsis dev's existingconfig importsave: saveEnvironmenttyping error (line 118 on dev, 163 here), untouched.tests/per-env-sessions.test.tsdrives the realcreateCli().run():configPath:config clear-session: active, named, unknown,--all, and--all+ name.default, andconfig clear-sessionclears it.config update-passwordwithBasicAuthStrategy:alice:oldbefore,alice:NEWafter.cli.runRoutine()clears only the active environment.HOME(os.homedir()ignores runtimeHOMEchanges):routine run(default,--json,--quiet) reuses the session.configPath,whoami/config switch/routine run/config clear-session [--all]leave a sentinel~/.<cli>/session.jsonbyte-identical.tests/session.test.tscovers:.gitignore, checked with realgit statusin a temp repo (exit codes asserted)0700/0600permissionsinvalidate/clear/clearAlltests/config.test.ts:resolveEnvironment()(active name, env-var null name, nothing configured), andsaveEnvironment()clearing only the saved environment's session.src/commands/config/clear-session/clear-session.test.ts: env-var target, not-configured, nothing-to-clear.dev(or against a targeted mutation) before the code went in. Mutations that fail at least one test:default.json*or device-name escapes.gitignorewritesaveEnvironment()saveEnvironment()now clears it, per the maintainer's ruling.currentEnvironmentcomment, the case-insensitive-filesystem comment, the env-varcli.runtest gap, a.gitignoretest that could pass vacuously, CLAUDE.md location wording, and session-file permissions.