Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.

## Release Notes

### 2026-08-30 — `v0.5.6`: rotated refresh tokens were only kept in memory

Supabase rotates the refresh token on every refresh and invalidates the previous one immediately. `createSupabaseClient` uses the client defaults (`autoRefreshToken: true`), and in Node the session store is in-memory only. The long-lived MCP server calls `setSession()` at boot and auto-refreshes on a 30s tick, so the rotated token lived in the server's memory and nowhere else — while the CLI and the server share the same `~/.config/tages/auth.json` on disk. The server was spending the CLI's on-disk refresh token every ~30 seconds without ever writing the new one back, so the on-disk credential eventually hit `refresh_token_already_used` and stayed dead. Confirmed empirically: `auth.json`'s mtime was still the original login while a direct call to the token endpoint returned `refresh_token_already_used`. `rlee@mersive.com`'s session died about an hour after login and stayed dead for four days; both pending `phoenix` teammates would have hit the same wall within an hour of joining.

- **`packages/shared/src/auth-store.ts` (new).** The single `auth.json` reader/writer, moved out of the CLI so the server uses the same code path. Writes atomically (temp file + `rename`), sets file mode `0600` unconditionally rather than relying on `writeFileSync`'s creation-only mode, and cleans up the temp file on any write failure.
- **`packages/shared/src/auth-persist.ts` (new).** `persistSessionOnRefresh` registers a Supabase `onAuthStateChange` listener that writes `TOKEN_REFRESHED` sessions back to `auth.json`. `persistRotatedTokens` refuses to write when the token already on disk has a later expiry than the incoming one, so a long-lived background process can't stomp a fresh `tages login` run from another terminal. Registered before `setSession()` at every long-lived call site — server `index.ts`/`config.ts`, CLI `auth/session.ts`, both `backfill-*.ts` scripts — because an expired access token makes `setSession` refresh immediately, and that first rotation is the one that was spending the disk token.
- **`init.ts`, `link.ts`, `migrate.ts`** had their own bare `writeFileSync` calls on `auth.json` and now route through the shared writer. `link` is the command a teammate runs to join a project; a truncate-then-write racing the server's `rename` could drop freshly minted OAuth tokens into an orphaned inode while still reporting success.
- **New `requireLiveSession` guard** on `team` (all 4 subcommands), `status`, and `onboard`. On a dead session these previously fell back to an anonymous client, read zero rows through RLS, and printed a clean "No team members" / "Memories: 0" / an empty briefing while exiting `0` — `tages team list` reported no members for a project that had two pending invites. The guard covers `anonymous` sessions as well as `expired` ones (`tages logout && tages team list` reproduces it in one step).
- **Not fixed, and worth stating plainly:** `persistRotatedTokens` is read-compare-write with no lock, so two processes refreshing at the same instant can still lose one rotation (better than always losing it, not zero). A hard kill between write and `rename` can leave an orphaned `auth.json.tmp.<pid>` at `0600` that nothing reaps. There's no `fsync` before `rename`, so the write is atomic against concurrent readers but not against power loss. 29 other CLI commands still go through `createAuthenticatedClient` with the same empty-vs-expired ambiguity this PR fixed for `team`/`status`/`onboard` — `recall-context`, `pending`, `brief`, and `query` are next.

**Versions:** `@tages/shared` 0.2.3 → 0.2.4, `@tages/server` 0.3.4 → 0.3.5, `@tages/cli` 0.5.5 → 0.5.6.

**Tests:** +12, each mutation-tested (verified to fail when the behavior under test was removed): the staleness guard, the `TOKEN_REFRESHED` event filter, the `team list` guard, the `anonymous` branch, the missing-token guard, the listener's write-failure catch, `readAuthFile`'s null returns, and temp-file cleanup on failure.

### 2026-08-25 — `v0.5.5`: six onboarding defects, found while provisioning a real team project

Every item here was found by actually provisioning a new project and inviting two teammates, not by reading the code. All six are on the first-run path, which is why none of the 1,500+ unit tests caught them.
Expand Down
4 changes: 2 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ This is the path for **creating a new project**. Joining a project a teammate al
```bash
npm install -g @tages/cli
tages --version
# 0.5.4
# 0.5.6
```

That is the whole install. The published packages are current — `@tages/cli` 0.5.4, `@tages/server` 0.3.4, `@tages/shared` 0.2.2 — and the end-to-end suite gates every release against the **published** artifacts, not the source tree, so npm is the path that is actually tested.
That is the whole install. The published packages are current — `@tages/cli` 0.5.6, `@tages/server` 0.3.5, `@tages/shared` 0.2.4 — and the end-to-end suite gates every release against the **published** artifacts, not the source tree, so npm is the path that is actually tested.

Your agent is wired to `npx -y @tages/server`, which needs no clone. Nothing to keep on disk, nothing to rebuild.

Expand Down
35 changes: 31 additions & 4 deletions docs/team-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ You need:
```bash
npm install -g @tages/cli
tages --version
# 0.5.4
# 0.5.6
```

The published packages are current (`@tages/cli` 0.5.4, `@tages/server` 0.3.4) and are what the end-to-end release suite actually tests — it drives the published artifacts, not the source tree, precisely because a defect that killed an earlier release candidate was invisible to 1,200+ unit tests and only appeared when the built entrypoint was run by `node`.
The published packages are current (`@tages/cli` 0.5.6, `@tages/server` 0.3.5) and are what the end-to-end release suite actually tests — it drives the published artifacts, not the source tree, precisely because a defect that killed an earlier release candidate was invisible to 1,200+ unit tests and only appeared when the built entrypoint was run by `node`.

You do **not** need a source clone. Your agent will be wired to `npx -y @tages/server`.

Expand Down Expand Up @@ -91,7 +91,7 @@ MCP server: npx -y @tages/server (published package)
MCP server: node /Users/you/src/tages/packages/server/dist/index.js (local build)
```

On an npm install you get the first, and that is correct — it resolves `@tages/server` 0.3.4, the same artifact the release suite gates on. You only get the second if you built from source, in which case `link` prefers your local build.
On an npm install you get the first, and that is correct — it resolves `@tages/server` 0.3.5, the same artifact the release suite gates on. You only get the second if you built from source, in which case `link` prefers your local build.

---

Expand Down Expand Up @@ -152,11 +152,38 @@ Project slugs are **globally unique across all owners** (`supabase/migrations/00

**If you are already in this state:** delete `~/.config/tages/projects/<slug>.json`, then re-run `tages link --project-id <uuid>`. Or join under a different local name with `tages link --project-id <uuid> --slug <alias>`.

### A session that dies on its own (fixed in 0.5.6 / 0.3.5 — upgrade)

Symptom: you log in successfully, everything works, and roughly an hour later
every command prints `Session expired. Run tages login`. Nobody logged out and
`auth.json` still has its original timestamp.

Cause: Supabase rotates the refresh token on every refresh and immediately
invalidates the previous one. The Supabase client auto-refreshes on a 30-second
tick, and the MCP server is long-lived, so it refreshed in the background and
kept the replacement **in memory only**. The token left on disk was spent, and
presenting it again fails with `refresh_token_already_used` — permanently. The
CLI and the MCP server share one `auth.json`, so the server quietly ended the
CLI's session.

Fixed by having every process that holds a session write the rotated token back
(`persistSessionOnRefresh`, `@tages/shared`). Older builds cannot be worked
around, only re-logged-into — so upgrade rather than re-running `tages login`
every hour:

```bash
npm install -g @tages/cli@latest
tages login
```

Nothing needs to change in `.mcp.json`; `npx -y @tages/server` picks up the new
server on your next Claude Code restart.

### Roles: you need `admin`, not `member`

Writes require owner or `admin`. `is_write_authorized` (`supabase/migrations/0031_rbac_write_policies.sql:14-25`) returns true only for the project owner or a `team_members` row with role `owner`/`admin`.

A `member` can read everything and write nothing. Their memories land in local SQLite and never sync — via the CLI you at least get the yellow `Stored locally only` warning, but **through the MCP `remember` tool the agent is told `Stored memory: ...` with no error at all** (`packages/server/src/tools/remember.ts:139-143` ignores the remote-write result). Since your agent uses the MCP path, a `member` will appear to be contributing to team memory for as long as nobody checks.
A `member` can read everything and write nothing. Their memories land in local SQLite and never sync. Both paths now say so: the CLI prints the yellow `Stored locally only` warning, and the MCP `remember` tool returns `Stored memory in the local cache only: ... teammates will NOT see this memory`, which is deliberately not the plain success wording so the agent relays the limitation instead of reporting a save (`packages/server/src/tools/remember.ts`). Earlier builds did ignore the remote-write result and reported plain success, so a `member` appeared to be contributing to team memory until somebody checked; if you are on a build older than 0.3.5, assume that is still true.

Both the CLI default (`tages team invite <email>`) and `tages init --team` invite as **`member`**. Owners must invite as admin explicitly:

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tages/cli",
"version": "0.5.5",
"version": "0.5.6",
"description": "CLI for Tages AI agent memory",
"type": "module",
"bin": {
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/__tests__/commands-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ const mockAuth = {
setSession: vi.fn().mockResolvedValue({ data: { session: { access_token: 'm' } }, error: null }),
getSession: vi.fn().mockResolvedValue({ data: { session: { access_token: 'm' } }, error: null }),
refreshSession: vi.fn().mockResolvedValue({ data: { session: { access_token: 'm', refresh_token: 'm' } }, error: null }),
// Every session-bearing client registers a TOKEN_REFRESHED listener so the
// rotated refresh token gets written back to auth.json. The real client
// always has this; a stub without it fails the command outright.
onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
}

const mockSupabase = {
Expand All @@ -122,9 +126,15 @@ function resetMockSupabase() {
mockAuth.setSession.mockClear()
mockAuth.getSession.mockClear()
mockAuth.refreshSession.mockClear()
mockAuth.onAuthStateChange.mockClear()
}

vi.mock('@tages/shared', () => ({
// Partial mock on purpose. A whole-module replacement has to be updated every
// time `shared` grows an export the CLI uses — it broke once already when the
// auth-file writer and the refresh-persistence hook moved into `shared` — and
// the failure surfaces as an unrelated command test, not as a missing import.
vi.mock('@tages/shared', async (importOriginal) => ({
...(await importOriginal<typeof import('@tages/shared')>()),
createSupabaseClient: vi.fn(() => mockSupabase),
}))

Expand Down Expand Up @@ -555,6 +565,10 @@ describe('onboard command', () => {
const setup = setupTempConfigDir()
tempConfigDir = setup.configDir
cleanupFn = setup.cleanup
// `onboard` refuses to build a briefing without a live session, because an
// anonymous client reads zero rows and renders as a project with nothing in
// it. These tests assert on briefing CONTENT, so they need a real session.
writeAuthConfig(tempConfigDir)
console_ = captureConsole()
resetMockSupabase()
})
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path'
import {
setupTempConfigDir,
writeProjectConfig,
writeAuthConfig,
captureConsole,
TEST_PROJECT_CONFIG,
TEST_LOCAL_CONFIG,
Expand All @@ -26,12 +27,28 @@ Object.defineProperty(mockChain, 'then', {
},
})

// `status` now refuses to run on a session that cannot read through RLS, so
// these tests have to present a live one. Without the `auth` surface below the
// real session resolver reports `anonymous`, and the command correctly exits 1
// rather than printing counts an anonymous client could never have fetched.
const mockSupabase = {
from: vi.fn().mockReturnValue(mockChain),
rpc: vi.fn(),
auth: {
setSession: vi.fn().mockResolvedValue({ data: { session: { access_token: 'm' } }, error: null }),
getSession: vi.fn().mockResolvedValue({ data: { session: { access_token: 'm' } }, error: null }),
refreshSession: vi
.fn()
.mockResolvedValue({ data: { session: { access_token: 'm', refresh_token: 'm' } }, error: null }),
onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
},
}

vi.mock('@tages/shared', () => ({
// Partial mock: a whole-module replacement has to be updated every time
// `shared` grows an export the CLI reaches, and the breakage shows up as an
// unrelated command test rather than a missing import.
vi.mock('@tages/shared', async (importOriginal) => ({
...(await importOriginal<typeof import('@tages/shared')>()),
createSupabaseClient: vi.fn(() => mockSupabase),
}))

Expand All @@ -55,6 +72,7 @@ describe('status command', () => {
const setup = setupTempConfigDir()
tempConfigDir = setup.configDir
cleanupFn = setup.cleanup
writeAuthConfig(tempConfigDir)
console_ = captureConsole()
vi.clearAllMocks()
// Reset mock data
Expand Down
115 changes: 106 additions & 9 deletions packages/cli/src/__tests__/team.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,41 @@ import {
} from './helpers.js'

// vi.mock factories are hoisted — use vi.hoisted() to share mocks.
const { mockLoadProjectConfig, mockCreateAuthenticatedClient, mockInviteTeamMembers } =
vi.hoisted(() => {
const mockLoadProjectConfig = vi.fn()
const mockCreateAuthenticatedClient = vi.fn()
const mockInviteTeamMembers = vi.fn()
return { mockLoadProjectConfig, mockCreateAuthenticatedClient, mockInviteTeamMembers }
})
const {
mockLoadProjectConfig,
mockCreateAuthenticatedClient,
mockInviteTeamMembers,
sessionStatus,
} = vi.hoisted(() => {
const mockLoadProjectConfig = vi.fn()
const mockCreateAuthenticatedClient = vi.fn()
const mockInviteTeamMembers = vi.fn()
// Boxed so a test can flip it after the hoisted mock factory has run.
const sessionStatus = {
value: 'authenticated' as 'authenticated' | 'expired' | 'anonymous',
}
return {
mockLoadProjectConfig,
mockCreateAuthenticatedClient,
mockInviteTeamMembers,
sessionStatus,
}
})

vi.mock('../config/project.js', () => ({
loadProjectConfig: mockLoadProjectConfig,
}))

vi.mock('../auth/session.js', () => ({
// Partial mock so `requireLiveSession` stays REAL. Stubbing it would make these
// tests pass against a guard that does nothing; only the client resolution is
// faked here, and the guard runs for real against `sessionStatus`.
vi.mock('../auth/session.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../auth/session.js')>()),
createAuthenticatedClient: mockCreateAuthenticatedClient,
createAuthenticatedClientWithStatus: async (...args: unknown[]) => ({
supabase: await mockCreateAuthenticatedClient(...args),
status: sessionStatus.value,
}),
}))

vi.mock('../auth/invite.js', () => ({
Expand All @@ -38,7 +59,7 @@ vi.mock('../config/paths.js', () => ({
getCacheDir: () => path.join(tempConfigDir, 'cache'),
}))

import { teamInviteCommand } from '../commands/team.js'
import { teamInviteCommand, teamListCommand } from '../commands/team.js'

describe('teamInviteCommand — invitable roles', () => {
let console_: ReturnType<typeof captureConsole>
Expand Down Expand Up @@ -208,3 +229,79 @@ describe('teamInviteCommand — invitable roles', () => {
)
})
})

describe('teamListCommand — expired session', () => {
let console_: ReturnType<typeof captureConsole>
let cleanupFn: () => void

beforeEach(() => {
const setup = setupTempConfigDir()
tempConfigDir = setup.configDir
cleanupFn = setup.cleanup
writeAuthConfig(tempConfigDir)
console_ = captureConsole()
mockLoadProjectConfig.mockReturnValue(TEST_PROJECT_CONFIG)
})

afterEach(() => {
sessionStatus.value = 'authenticated'
console_.restore()
cleanupFn()
vi.clearAllMocks()
})

it.each([
['expired', /session has expired/i],
['anonymous', /not signed in/i],
] as const)('fails loudly on a %s session instead of reporting an empty team', async (
state,
expected,
) => {
// `anonymous` matters as much as `expired`: it is one step away via
// `tages logout && tages team list`, and it produces byte-identical
// output through a different door.
sessionStatus.value = state
mockCreateAuthenticatedClient.mockResolvedValue({
from: () => {
throw new Error('must not query without a live session')
},
})

const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as never)

await expect(teamListCommand({} as never)).rejects.toThrow('process.exit')

expect(exit).toHaveBeenCalledWith(1)
const output = console_.errors.join('\n') + console_.logs.join('\n')
expect(output).toMatch(expected)
expect(output).not.toMatch(/No team members/)
exit.mockRestore()
})

it('fails loudly instead of reporting an empty team', async () => {
// The bug this pins: an expired session hands back an anonymous client,
// RLS returns zero rows, and `team list` printed
// "No team members. Run `tages team invite <email>` to add one."
// for a project that had two pending invites — then exited 0.
sessionStatus.value = 'expired'
mockCreateAuthenticatedClient.mockResolvedValue({
from: () => {
throw new Error('must not query on an expired session')
},
})

const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit')
}) as never)

await expect(teamListCommand({} as never)).rejects.toThrow('process.exit')

expect(exit).toHaveBeenCalledWith(1)
const output = console_.errors.join('\n') + console_.logs.join('\n')
expect(output).toMatch(/session has expired/i)
expect(output).not.toMatch(/No team members/)
exit.mockRestore()
})
})
Loading