@astrale-os/cli 1.0.0-beta.28 → 1.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +33 -0
  2. package/dist/astrale.js +1629 -1088
  3. package/dist/public/connect-core.js +86 -17
  4. package/dist/public/keys/index.js +69 -2
  5. package/dist/public/paths/index.js +67 -0
  6. package/dist/types/connection/session.d.ts +2 -2
  7. package/dist/types/lib/config.d.ts +6 -0
  8. package/dist/types/state/exchange-credentials.d.ts +6 -2
  9. package/dist/types/state/files.d.ts +2 -0
  10. package/dist/types/state/index.d.ts +3 -2
  11. package/dist/types/state/paths.d.ts +2 -0
  12. package/dist/types/state/session-routes.d.ts +10 -0
  13. package/package.json +2 -2
  14. package/src/commands/auth/logout.ts +2 -1
  15. package/src/commands/browser.ts +29 -0
  16. package/src/connection/.spec/architecture.md +9 -1
  17. package/src/connection/__tests__/auth.test.ts +1 -0
  18. package/src/connection/__tests__/credential.test.ts +1 -0
  19. package/src/connection/__tests__/exchange.test.ts +34 -10
  20. package/src/connection/__tests__/session.test.ts +5 -1
  21. package/src/connection/__tests__/target.test.ts +1 -0
  22. package/src/connection/exchange.ts +63 -38
  23. package/src/connection/session.ts +8 -1
  24. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  25. package/src/identity/__tests__/registry.test.ts +4 -0
  26. package/src/identity/registry.ts +2 -0
  27. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  28. package/src/lib/__tests__/config.test.ts +27 -0
  29. package/src/lib/browser-retention.ts +210 -0
  30. package/src/lib/config.ts +12 -1
  31. package/src/state/.spec/api.d.ts +21 -2
  32. package/src/state/.spec/architecture.md +18 -4
  33. package/src/state/.spec/layout.ts +1 -0
  34. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  35. package/src/state/__tests__/files.test.ts +24 -1
  36. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  37. package/src/state/__tests__/paths.test.ts +1 -0
  38. package/src/state/__tests__/session-routes.test.ts +138 -0
  39. package/src/state/exchange-credentials.ts +22 -9
  40. package/src/state/files.ts +41 -0
  41. package/src/state/index.ts +3 -1
  42. package/src/state/paths.ts +3 -0
  43. package/src/state/session-routes.ts +34 -0
  44. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  45. package/src/telemetry/__tests__/retention.test.ts +88 -1
  46. package/src/telemetry/analyze.ts +24 -2
  47. package/src/telemetry/retention.ts +53 -6
  48. package/src/telemetry/store.ts +5 -0
  49. package/studio/package.json +1 -1
  50. package/viewer/dist/main.js +28 -28
@@ -28,7 +28,15 @@ selection, and the single safe stale-route recovery. The CLI does not reproduce
28
28
  discover destination identity. `SessionAuth.resolve(call, signal)` returns source-Kernel authority and
29
29
  ClientSession supplies an audience-free Delegate with omitted attenuation, preserving the exact
30
30
  current Grant for routing. Connection itself persists no credential or route; the separate state
31
- owner persists only exchanged source credentials.
31
+ owner persists exchanged source credentials and the separate Kernel Client route artifact. A valid exchanged credential is selected by the
32
+ authenticated source issuer and subject before any live `whoami`; cache misses alone resolve the
33
+ registered Kernel User and perform delegation plus Domain exchange.
34
+ Exchange authority is bounded to five minutes and never outlives the current source credential,
35
+ matching the CLI route-age ceiling without forcing an unrelated two-minute refresh cycle.
36
+
37
+ Every ClientSession receives the CLI-owned `state/session-routes` representation capability. Kernel
38
+ Client still owns route keying, admission, expiry, and one safe stale/miss recovery; Connection does
39
+ not reproduce routing or add an Admin-only bypass.
32
40
 
33
41
  `--anonymous` deliberately suppresses ambient and bookmark-default identities by omitting the
34
42
  `SessionAuth` capability. It cannot be combined with `--as` or `--creds`; contradictory selections fail
@@ -15,6 +15,7 @@ const config: AstraleConfig = {
15
15
  kernelIssuer: 'https://admin.eu.astrale.ai/api',
16
16
  },
17
17
  telemetry: { enabled: true },
18
+ browser: {},
18
19
  }
19
20
 
20
21
  describe('resolveKeyIdentityAuthOptions', () => {
@@ -18,6 +18,7 @@ const config: AstraleConfig = {
18
18
  issuer: 'https://cli.example',
19
19
  admin: { name: 'admin', url: SOURCE, kernelIssuer: SOURCE },
20
20
  telemetry: { enabled: false },
21
+ browser: {},
21
22
  }
22
23
 
23
24
  describe('connection credential', () => {
@@ -14,13 +14,8 @@ const DOMAIN = issuer.accept('https://admin.example')
14
14
  const TARGET = { url: `${KERNEL}/api`, kernelIssuer: KERNEL, domainIssuer: DOMAIN }
15
15
  const INVOCATION = `${KERNEL}/invoke`
16
16
  const EXPIRES_AT = Math.floor(Date.now() / 1_000) + 500
17
- const SOURCE_EXPIRES_AT = Math.floor(Date.now() / 1_000) + 120
18
- const SOURCE_TOKEN = token(
19
- issuer.accept('https://workos.example'),
20
- KERNEL,
21
- 'user-1',
22
- SOURCE_EXPIRES_AT,
23
- )
17
+ const SOURCE_EXPIRES_AT = Math.floor(Date.now() / 1_000) + 600
18
+ const SOURCE_TOKEN = sourceToken('user-1')
24
19
  let directory: string
25
20
 
26
21
  beforeEach(async () => {
@@ -33,7 +28,7 @@ afterEach(async () => {
33
28
 
34
29
  describe('Domain token exchange', () => {
35
30
  /** @evidence TEST-CLI-EXCHANGE-WHOAMI-DELEGATE-EXCHANGE-CACHE */
36
- test('runs the exact User to Kernel to Domain journey and reuses the bound token', async () => {
31
+ test('runs the exact User to Kernel to Domain journey once and reuses it before whoami', async () => {
37
32
  const observed: Array<{
38
33
  url: string
39
34
  init: RequestInit | undefined
@@ -91,7 +86,7 @@ describe('Domain token exchange', () => {
91
86
  await expect(resolver.resolve(KERNEL, new AbortController().signal)).resolves.toBe(exchanged)
92
87
 
93
88
  const kernelRequests = observed.filter((entry) => entry.url === INVOCATION)
94
- expect(kernelRequests).toHaveLength(3)
89
+ expect(kernelRequests).toHaveLength(2)
95
90
  expect(kernelRequests[0]!.body).toMatchObject({
96
91
  credential: SOURCE_TOKEN,
97
92
  call: { input: {} },
@@ -107,13 +102,32 @@ describe('Domain token exchange', () => {
107
102
  })
108
103
  const delegatedTtl = kernelRequests[1]!.body!.call.input.ttlSeconds
109
104
  expect(delegatedTtl).toBeGreaterThan(0)
110
- expect(delegatedTtl).toBeLessThan(120)
105
+ expect(delegatedTtl).toBeGreaterThanOrEqual(295)
106
+ expect(delegatedTtl).toBeLessThanOrEqual(300)
111
107
  expect(sourceAudiences).toEqual([KERNEL, KERNEL])
112
108
  expect(
113
109
  observed.filter((entry) => entry.url.endsWith('/.well-known/astrale/token')),
114
110
  ).toHaveLength(1)
115
111
  })
116
112
 
113
+ test('rejects a source credential without a stable cache identity before network I/O', async () => {
114
+ let fetches = 0
115
+ const resolver = createExchangeCredentialResolver(
116
+ TARGET,
117
+ { resolve: async () => sourceToken(undefined) },
118
+ async () => {
119
+ fetches += 1
120
+ throw new Error('network must remain untouched')
121
+ },
122
+ 5_000,
123
+ new ExchangeCredentialCache(join(directory, 'credentials.json')),
124
+ )
125
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
126
+ code: 'TOKEN_EXCHANGE_SOURCE_INVALID',
127
+ })
128
+ expect(fetches).toBe(0)
129
+ })
130
+
117
131
  /** @evidence TEST-CLI-EXCHANGE-NO-LEGACY-FALLBACK */
118
132
  test('fails closed when issuer discovery does not advertise exchange', async () => {
119
133
  const fetch: Fetch = async (input, init) => {
@@ -293,3 +307,13 @@ function token(iss: string, aud: string, user: string, exp: number): string {
293
307
  grant: { v: 1, expr: { kind: 'identity', credential: proof } },
294
308
  })}.signature`
295
309
  }
310
+
311
+ function sourceToken(subject: string | undefined): string {
312
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
313
+ return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
314
+ iss: 'https://workos.example',
315
+ ...(subject === undefined ? {} : { sub: subject }),
316
+ aud: KERNEL,
317
+ exp: SOURCE_EXPIRES_AT,
318
+ })}.signature`
319
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AuthApi } from '@astrale-os/sdk/auth'
2
2
  import type { GraphApi } from '@astrale-os/sdk/client'
3
3
  import type { ClientSession } from '@astrale-os/sdk/client/session'
4
+ import type { SessionRouteStore } from '@astrale-os/sdk/client/session'
4
5
 
5
6
  import { issuer } from '@astrale-os/sdk/auth'
6
7
  import { describe, expect, test } from 'bun:test'
@@ -23,6 +24,7 @@ const config: AstraleConfig = {
23
24
  kernelIssuer: 'https://admin.example',
24
25
  },
25
26
  telemetry: { enabled: false },
27
+ browser: {},
26
28
  }
27
29
 
28
30
  const context: ConnectionContext = Object.freeze({
@@ -37,11 +39,13 @@ describe('connection session', () => {
37
39
  /** @evidence TEST-CLI-CONNECTION-PINS-SOURCE-ISSUER */
38
40
  test('pins the selected canonical Kernel issuer without a transport escape hatch', async () => {
39
41
  const auth = { ttlSeconds: 3_600, resolve: async () => ({ credential: 'credential' }) }
40
- const options = createClientSessionOptions(target, globalThis.fetch, auth, 2_500)
42
+ const routeStore: SessionRouteStore = { read: () => undefined, write: () => undefined }
43
+ const options = createClientSessionOptions(target, globalThis.fetch, auth, 2_500, routeStore)
41
44
 
42
45
  expect(options.kernel).toBe(target.kernelIssuer)
43
46
  expect(options).not.toHaveProperty('url')
44
47
  expect(options).not.toHaveProperty('sourceIssuer')
48
+ expect(options.routeStore).toBe(routeStore)
45
49
  })
46
50
 
47
51
  /** @evidence TEST-CLI-CONNECTION-OMITS-EXPLICIT-ANONYMOUS-CREDENTIAL */
@@ -15,6 +15,7 @@ const config: AstraleConfig = {
15
15
  domainIssuer: 'https://admin-domain.example',
16
16
  },
17
17
  telemetry: { enabled: false },
18
+ browser: {},
18
19
  }
19
20
 
20
21
  const instances: InstanceStore = {
@@ -11,7 +11,7 @@ import type { ConnectionTarget } from './target'
11
11
  import { AstraleError } from '../errors'
12
12
  import { ExchangeCredentialCache } from '../state/exchange-credentials'
13
13
 
14
- const EXCHANGE_TTL_SECONDS = 120
14
+ const EXCHANGE_TTL_SECONDS = 5 * 60
15
15
  const MAXIMUM_RESPONSE_BYTES = 256 * 1024
16
16
 
17
17
  /** Exchange exact authenticated User authority for a Domain bearer bound to this Kernel. */
@@ -27,53 +27,78 @@ export function createExchangeCredentialResolver(
27
27
  async resolve(kernelIssuer: IssuerId, signal: AbortSignal): Promise<string> {
28
28
  requireLive(signal)
29
29
  const sourceToken = await source.resolve(kernelIssuer, signal)
30
- const delegationTtlSeconds = delegationLifetime(sourceToken)
30
+ const sourceIdentity = sourceCacheIdentity(sourceToken)
31
31
  requireLive(signal)
32
32
 
33
- const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
34
- try {
35
- const authenticated = client.as(sourceToken)
36
- const auth = createAuth(async (path, input, options) => {
37
- const result = await authenticated.call(call(path, input), {
38
- ...options,
39
- delegate: { ttlSeconds: delegationTtlSeconds },
40
- })
41
- return result.value
42
- })
43
- const user = await auth.whoami({ signal })
44
- const key = Object.freeze({
33
+ return await cache.getOrRefresh(
34
+ Object.freeze({
45
35
  kernelIssuer,
46
36
  domainIssuer: target.domainIssuer,
47
- user: user.id,
48
- })
49
- return await cache.getOrRefresh(key, async () => {
50
- let envelope: string | undefined
51
- for (let attempt = 0; attempt < 3; attempt += 1) {
52
- try {
53
- envelope = await auth.delegate(
54
- user.id,
55
- {
56
- audience: target.domainIssuer,
57
- ttlSeconds: delegationTtlSeconds,
58
- attenuation: { kind: 'identity', self: true },
59
- },
60
- { signal },
61
- )
62
- break
63
- } catch (cause) {
64
- if (attempt === 2 || !unknownFunctionOutcome(cause)) throw cause
37
+ sourceIssuer: sourceIdentity.issuer,
38
+ sourceSubject: sourceIdentity.subject,
39
+ }),
40
+ async () => {
41
+ const delegationTtlSeconds = delegationLifetime(sourceToken)
42
+ const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
43
+ try {
44
+ const authenticated = client.as(sourceToken)
45
+ const auth = createAuth(async (path, input, options) => {
46
+ const result = await authenticated.call(call(path, input), {
47
+ ...options,
48
+ delegate: { ttlSeconds: delegationTtlSeconds },
49
+ })
50
+ return result.value
51
+ })
52
+ const user = await auth.whoami({ signal })
53
+ let envelope: string | undefined
54
+ for (let attempt = 0; attempt < 3; attempt += 1) {
55
+ try {
56
+ envelope = await auth.delegate(
57
+ user.id,
58
+ {
59
+ audience: target.domainIssuer,
60
+ ttlSeconds: delegationTtlSeconds,
61
+ attenuation: { kind: 'identity', self: true },
62
+ },
63
+ { signal },
64
+ )
65
+ break
66
+ } catch (cause) {
67
+ if (attempt === 2 || !unknownFunctionOutcome(cause)) throw cause
68
+ }
65
69
  }
70
+ if (envelope === undefined) throw new Error('Token delegation returned no credential.')
71
+ return {
72
+ ...(await exchange(target.domainIssuer, kernelIssuer, envelope, fetch, signal)),
73
+ user: user.id,
74
+ sourceIssuer: sourceIdentity.issuer,
75
+ sourceSubject: sourceIdentity.subject,
76
+ }
77
+ } finally {
78
+ client.close()
66
79
  }
67
- if (envelope === undefined) throw new Error('Token delegation returned no credential.')
68
- return exchange(target.domainIssuer, kernelIssuer, envelope, fetch, signal)
69
- })
70
- } finally {
71
- client.close()
72
- }
80
+ },
81
+ )
73
82
  },
74
83
  })
75
84
  }
76
85
 
86
+ function sourceCacheIdentity(sourceToken: string): { issuer: string; subject: string } {
87
+ const inspected = credential.inspect(sourceToken)
88
+ if (
89
+ typeof inspected.iss !== 'string' ||
90
+ inspected.iss.length === 0 ||
91
+ typeof inspected.sub !== 'string' ||
92
+ inspected.sub.length === 0
93
+ ) {
94
+ throw new AstraleError(
95
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
96
+ 'The source identity credential has no stable issuer and subject.',
97
+ )
98
+ }
99
+ return Object.freeze({ issuer: inspected.iss, subject: inspected.sub })
100
+ }
101
+
77
102
  function unknownFunctionOutcome(cause: unknown): boolean {
78
103
  if (cause === null || typeof cause !== 'object') return false
79
104
  const error = cause as { readonly code?: unknown; readonly reason?: unknown }
@@ -1,6 +1,10 @@
1
1
  import type { AuthApi } from '@astrale-os/sdk/auth'
2
2
  import type { GraphApi } from '@astrale-os/sdk/client'
3
- import type { ClientSessionOptions, SessionAuth } from '@astrale-os/sdk/client/session'
3
+ import type {
4
+ ClientSessionOptions,
5
+ SessionAuth,
6
+ SessionRouteStore,
7
+ } from '@astrale-os/sdk/client/session'
4
8
 
5
9
  import { createAuth } from '@astrale-os/sdk/auth'
6
10
  import { call } from '@astrale-os/sdk/client'
@@ -21,6 +25,7 @@ import { fetchWithCaFile } from '../lib/ca-fetch'
21
25
  import { readConfig } from '../lib/config'
22
26
  import { log } from '../lib/log'
23
27
  import { isMachine } from '../lib/output'
28
+ import { SESSION_ROUTE_STORE } from '../state/session-routes'
24
29
  import { bindCredentialIdentity } from './auth'
25
30
  import { createCliCredential, validateCredentialSelection } from './credential'
26
31
  import { resolveAdminConnectionTarget, resolveConnectionTarget } from './target'
@@ -165,11 +170,13 @@ export function createClientSessionOptions(
165
170
  fetch: NonNullable<ClientSessionOptions['fetch']>,
166
171
  auth: SessionAuth | undefined,
167
172
  timeoutMs: number,
173
+ routeStore: SessionRouteStore = SESSION_ROUTE_STORE,
168
174
  ): ClientSessionOptions {
169
175
  return {
170
176
  kernel: target.kernelIssuer,
171
177
  fetch,
172
178
  ...(auth === undefined ? {} : { auth }),
179
+ routeStore,
173
180
  policy: {
174
181
  maximumRouteAgeMs: MAXIMUM_ROUTE_AGE_MS,
175
182
  ...(new URL(target.url).protocol === 'http:' ? { allowInsecureHttp: true } : {}),
@@ -1,5 +1,8 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
1
4
  import { fileExists, keypairPaths } from '../../../keys/index'
2
- import { KEYS_DIR } from '../../../state/index'
5
+ import { EXCHANGE_CREDENTIALS_PATH, KEYS_DIR, SESSION_ROUTES_PATH } from '../../../state/index'
3
6
  import {
4
7
  createIdentity,
5
8
  exportIdentity,
@@ -30,6 +33,13 @@ try {
30
33
  } catch (error) {
31
34
  selectedDeletion = error instanceof Error ? error.message : String(error)
32
35
  }
36
+ await mkdir(dirname(SESSION_ROUTES_PATH), { recursive: true })
37
+ await mkdir(dirname(EXCHANGE_CREDENTIALS_PATH), { recursive: true })
38
+ await writeFile(SESSION_ROUTES_PATH, '{"version":1,"entries":{"confidential":{}}}\n')
39
+ await writeFile(
40
+ EXCHANGE_CREDENTIALS_PATH,
41
+ '{"version":2,"entries":{"confidential":{"credential":"bearer"}}}\n',
42
+ )
33
43
  await deleteIdentity('bob')
34
44
  await deleteIdentity('alice-alias')
35
45
  await upsertIdpIdentity('workos', {
@@ -49,5 +59,7 @@ console.log(
49
59
  selectedDeletion,
50
60
  aliceKey: await fileExists(keypairPaths('alice', KEYS_DIR).privatePath),
51
61
  bobKey: await fileExists(keypairPaths('bob', KEYS_DIR).privatePath),
62
+ routeCache: await fileExists(SESSION_ROUTES_PATH),
63
+ exchangeCache: JSON.parse(await readFile(EXCHANGE_CREDENTIALS_PATH, 'utf8')),
52
64
  }),
53
65
  )
@@ -47,6 +47,8 @@ describe('identity registry journey', () => {
47
47
  selectedDeletion: string
48
48
  aliceKey: boolean
49
49
  bobKey: boolean
50
+ routeCache: boolean
51
+ exchangeCache: unknown
50
52
  }
51
53
  expect(result.store.default).toBe('alice')
52
54
  expect(Object.keys(result.store.identities).sort()).toEqual(['alice', 'workos'])
@@ -60,6 +62,8 @@ describe('identity registry journey', () => {
60
62
  expect(result.selectedDeletion).toContain('Cannot delete the default identity')
61
63
  expect(result.aliceKey).toBe(true)
62
64
  expect(result.bobKey).toBe(false)
65
+ expect(result.routeCache).toBe(false)
66
+ expect(result.exchangeCache).toEqual({ version: 2, entries: {} })
63
67
 
64
68
  const persisted = JSON.parse(await readFile(join(root, 'identities.json'), 'utf-8')) as {
65
69
  version?: unknown
@@ -10,6 +10,7 @@ import {
10
10
  type IdentityStore,
11
11
  type Registration,
12
12
  ExchangeCredentialCache,
13
+ SESSION_ROUTE_STORE,
13
14
  } from '../state/index'
14
15
 
15
16
  export type { Identity, IdentityStore, Registration } from '../state/index'
@@ -68,6 +69,7 @@ export async function deleteIdentity(name: string): Promise<void> {
68
69
  return { next: { ...store, identities }, value: undefined }
69
70
  })
70
71
  await new ExchangeCredentialCache().clear()
72
+ SESSION_ROUTE_STORE.clear()
71
73
  }
72
74
 
73
75
  function hasAnotherKeyIdentity(store: IdentityStore, name: string, subject: string): boolean {
@@ -0,0 +1,212 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { hostname, tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ import type { BrowserRetentionBudget } from '../browser-retention'
15
+
16
+ import {
17
+ DEFAULT_MAX_CACHE_BYTES,
18
+ DEFAULT_MAX_PROFILE_AGE_DAYS,
19
+ browserRetentionBudget,
20
+ heldByLiveBrowser,
21
+ profileCacheBytes,
22
+ sweepBrowserProfiles,
23
+ } from '../browser-retention'
24
+
25
+ const DAY = 24 * 60 * 60 * 1000
26
+ const BUDGET: BrowserRetentionBudget = { maxCacheBytes: 10_000, maxProfileAgeMs: 30 * DAY }
27
+
28
+ let dir: string
29
+
30
+ beforeEach(() => {
31
+ dir = mkdtempSync(join(tmpdir(), 'astrale-browser-ret-'))
32
+ delete process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES
33
+ delete process.env.ASTRALE_BROWSER_MAX_PROFILE_AGE_DAYS
34
+ })
35
+
36
+ afterEach(() => {
37
+ rmSync(dir, { recursive: true, force: true })
38
+ })
39
+
40
+ type Seed = { idleMs?: number; cacheBytes?: number; lockPid?: number }
41
+
42
+ /** A profile shaped like Chromium's: session state at the root and in Default/,
43
+ * cache in the subdirectories retention is allowed to delete. */
44
+ function seedProfile(name: string, { idleMs = 0, cacheBytes = 0, lockPid }: Seed): string {
45
+ const profile = join(dir, name)
46
+ mkdirSync(join(profile, 'Default'), { recursive: true })
47
+ writeFileSync(join(profile, 'Local State'), '{"os_crypt":{"encrypted_key":"x"}}')
48
+ writeFileSync(join(profile, 'Default', 'Cookies'), 'SQLite format 3\0cookie-data')
49
+ writeFileSync(join(profile, 'Default', 'Preferences'), '{"profile":{}}')
50
+ mkdirSync(join(profile, 'Default', 'Local Storage', 'leveldb'), { recursive: true })
51
+ writeFileSync(join(profile, 'Default', 'Local Storage', 'leveldb', '000003.log'), 'state')
52
+
53
+ if (cacheBytes > 0) {
54
+ mkdirSync(join(profile, 'Default', 'Cache', 'Cache_Data'), { recursive: true })
55
+ writeFileSync(
56
+ join(profile, 'Default', 'Cache', 'Cache_Data', 'f_000001'),
57
+ 'x'.repeat(cacheBytes),
58
+ )
59
+ mkdirSync(join(profile, 'Default', 'Code Cache', 'js'), { recursive: true })
60
+ writeFileSync(join(profile, 'Default', 'Code Cache', 'js', 'index'), 'y'.repeat(cacheBytes))
61
+ }
62
+ if (lockPid !== undefined) {
63
+ symlinkSync(`${hostname()}-${lockPid}`, join(profile, 'SingletonLock'))
64
+ }
65
+ const when = new Date(Date.now() - idleMs)
66
+ utimesSync(profile, when, when)
67
+ return profile
68
+ }
69
+
70
+ const sessionIntact = (name: string): boolean =>
71
+ existsSync(join(dir, name, 'Default', 'Cookies')) &&
72
+ existsSync(join(dir, name, 'Local State')) &&
73
+ existsSync(join(dir, name, 'Default', 'Preferences')) &&
74
+ existsSync(join(dir, name, 'Default', 'Local Storage', 'leveldb', '000003.log'))
75
+
76
+ const cacheGone = (name: string): boolean =>
77
+ !existsSync(join(dir, name, 'Default', 'Cache')) &&
78
+ !existsSync(join(dir, name, 'Default', 'Code Cache'))
79
+
80
+ describe('age rule', () => {
81
+ test('a dormant profile is removed outright', async () => {
82
+ seedProfile('dormant', { idleMs: 40 * DAY, cacheBytes: 100 })
83
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
84
+ expect(r.removed).toEqual(['dormant'])
85
+ expect(existsSync(join(dir, 'dormant'))).toBe(false)
86
+ expect(r.bytesFreed).toBeGreaterThan(0)
87
+ })
88
+
89
+ test('a recently used profile is kept', async () => {
90
+ seedProfile('fresh', { idleMs: 2 * DAY, cacheBytes: 100 })
91
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
92
+ expect(r.removed).toEqual([])
93
+ expect(sessionIntact('fresh')).toBe(true)
94
+ })
95
+
96
+ test('the age bound is configurable', async () => {
97
+ seedProfile('two-days', { idleMs: 2 * DAY })
98
+ const strict = { ...BUDGET, maxProfileAgeMs: DAY }
99
+ expect((await sweepBrowserProfiles({ dir, budget: strict })).removed).toEqual(['two-days'])
100
+ })
101
+ })
102
+
103
+ describe('size rule', () => {
104
+ test('a profile under budget is left completely alone', async () => {
105
+ seedProfile('small', { idleMs: DAY, cacheBytes: 1_000 })
106
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
107
+ expect(r.purged).toEqual([])
108
+ expect(cacheGone('small')).toBe(false)
109
+ })
110
+
111
+ test('a profile over budget loses its cache but keeps its session', async () => {
112
+ seedProfile('bloated', { idleMs: DAY, cacheBytes: 20_000 })
113
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
114
+ expect(r.purged).toEqual(['bloated'])
115
+ expect(cacheGone('bloated')).toBe(true)
116
+ // The entire point: the cookie survives, so the user stays signed in.
117
+ expect(sessionIntact('bloated')).toBe(true)
118
+ expect(existsSync(join(dir, 'bloated'))).toBe(true)
119
+ expect(r.bytesFreed).toBeGreaterThanOrEqual(40_000)
120
+ })
121
+
122
+ test('the size bound counts only cache directories, not the whole profile', async () => {
123
+ const profile = seedProfile('measured', { idleMs: DAY, cacheBytes: 3_000 })
124
+ writeFileSync(join(profile, 'Default', 'History'), 'z'.repeat(50_000))
125
+ // 2 cache files of 3 KB each; History is not cache and must not count.
126
+ expect(await profileCacheBytes(profile)).toBe(6_000)
127
+ expect((await sweepBrowserProfiles({ dir, budget: BUDGET })).purged).toEqual([])
128
+ })
129
+ })
130
+
131
+ describe('live-browser guard', () => {
132
+ test('a profile locked by a live process is never touched', async () => {
133
+ // Our own pid is unambiguously alive.
134
+ seedProfile('in-use', { idleMs: 40 * DAY, cacheBytes: 20_000, lockPid: process.pid })
135
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
136
+ expect(r.skipped).toEqual(['in-use'])
137
+ expect(r.removed).toEqual([])
138
+ expect(r.purged).toEqual([])
139
+ expect(existsSync(join(dir, 'in-use'))).toBe(true)
140
+ expect(cacheGone('in-use')).toBe(false)
141
+ })
142
+
143
+ test('a stale lock from a dead process does not protect anything', async () => {
144
+ // Chromium leaves these behind after a crash; they must not pin disk forever.
145
+ seedProfile('crashed', { idleMs: 40 * DAY, lockPid: 999_999 })
146
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
147
+ expect(r.removed).toEqual(['crashed'])
148
+ })
149
+
150
+ test('heldByLiveBrowser reads the symlink without resolving it', () => {
151
+ // The target names a file that does not exist — statting it would throw.
152
+ const live = seedProfile('live', { lockPid: process.pid })
153
+ const dead = seedProfile('dead', { lockPid: 999_999 })
154
+ const none = seedProfile('none', {})
155
+ expect(heldByLiveBrowser(live)).toBe(true)
156
+ expect(heldByLiveBrowser(dead)).toBe(false)
157
+ expect(heldByLiveBrowser(none)).toBe(false)
158
+ })
159
+ })
160
+
161
+ describe('sweep resilience', () => {
162
+ test('a missing browser directory is a no-op, not an error', async () => {
163
+ const gone = join(dir, 'does-not-exist')
164
+ const r = await sweepBrowserProfiles({ dir: gone, budget: BUDGET })
165
+ expect(r).toEqual({ removed: [], purged: [], skipped: [], bytesFreed: 0 })
166
+ })
167
+
168
+ test('stray files beside the profiles are ignored', async () => {
169
+ writeFileSync(join(dir, 'browser.json'), '{}')
170
+ seedProfile('real', { idleMs: 40 * DAY })
171
+ expect((await sweepBrowserProfiles({ dir, budget: BUDGET })).removed).toEqual(['real'])
172
+ })
173
+
174
+ test('several profiles are handled independently in one pass', async () => {
175
+ seedProfile('a-dormant', { idleMs: 40 * DAY })
176
+ seedProfile('b-bloated', { idleMs: DAY, cacheBytes: 20_000 })
177
+ seedProfile('c-fine', { idleMs: DAY, cacheBytes: 100 })
178
+ seedProfile('d-locked', { idleMs: 40 * DAY, lockPid: process.pid })
179
+ const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
180
+ expect(r.removed).toEqual(['a-dormant'])
181
+ expect(r.purged).toEqual(['b-bloated'])
182
+ expect(r.skipped).toEqual(['d-locked'])
183
+ expect(sessionIntact('c-fine')).toBe(true)
184
+ })
185
+ })
186
+
187
+ describe('browserRetentionBudget', () => {
188
+ test('defaults when nothing is configured', async () => {
189
+ expect(await browserRetentionBudget()).toEqual({
190
+ maxCacheBytes: DEFAULT_MAX_CACHE_BYTES,
191
+ maxProfileAgeMs: DEFAULT_MAX_PROFILE_AGE_DAYS * DAY,
192
+ })
193
+ })
194
+
195
+ test('env overrides both bounds', async () => {
196
+ process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES = '4096'
197
+ process.env.ASTRALE_BROWSER_MAX_PROFILE_AGE_DAYS = '3'
198
+ expect(await browserRetentionBudget()).toEqual({
199
+ maxCacheBytes: 4096,
200
+ maxProfileAgeMs: 3 * DAY,
201
+ })
202
+ })
203
+
204
+ test.each([
205
+ ['zero', '0'],
206
+ ['negative', '-1'],
207
+ ['not a number', 'lots'],
208
+ ])('a %s env value falls back to the default rather than removing the cap', async (_l, value) => {
209
+ process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES = value
210
+ expect((await browserRetentionBudget()).maxCacheBytes).toBe(DEFAULT_MAX_CACHE_BYTES)
211
+ })
212
+ })
@@ -23,6 +23,33 @@ describe('AstraleConfigSchema', () => {
23
23
  })
24
24
  })
25
25
 
26
+ test('retention bounds survive a parse — a read/write cycle must not drop them', () => {
27
+ // zod strips unknown keys, so a bound declared only in its reader would be
28
+ // silently erased by any command that rewrites the config.
29
+ const result = AstraleConfigSchema.parse({
30
+ telemetry: { enabled: true, maxAgeDays: 7, maxBytes: 1_048_576 },
31
+ browser: { maxCacheBytes: 52_428_800, maxProfileAgeDays: 14 },
32
+ })
33
+ expect(result.telemetry).toEqual({ enabled: true, maxAgeDays: 7, maxBytes: 1_048_576 })
34
+ expect(result.browser).toEqual({ maxCacheBytes: 52_428_800, maxProfileAgeDays: 14 })
35
+ })
36
+
37
+ test.each([
38
+ ['zero', 0],
39
+ ['negative', -5],
40
+ ['not a number', 'lots'],
41
+ ['infinite', Number.POSITIVE_INFINITY],
42
+ ])('a %s bound is dropped, and does not take the rest of the config with it', (_l, value) => {
43
+ const result = AstraleConfigSchema.parse({
44
+ issuer: 'https://test.astrale.ai',
45
+ telemetry: { enabled: true, maxAgeDays: value },
46
+ browser: { maxCacheBytes: value },
47
+ })
48
+ expect(result.telemetry.maxAgeDays).toBeUndefined()
49
+ expect(result.browser.maxCacheBytes).toBeUndefined()
50
+ expect(result.issuer).toBe('https://test.astrale.ai')
51
+ })
52
+
26
53
  test('rejects non-url issuer', () => {
27
54
  expect(() => AstraleConfigSchema.parse({ issuer: 'not-a-url' })).toThrow()
28
55
  })