@astrale-os/cli 1.0.0-beta.27 → 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 (108) hide show
  1. package/README.md +59 -3
  2. package/dist/astrale.js +1765 -1057
  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/__tests__/domain-install-operation.test.ts +23 -0
  15. package/src/commands/__tests__/install-identity-override.test.ts +3 -3
  16. package/src/commands/auth/logout.ts +2 -1
  17. package/src/commands/browser.ts +29 -0
  18. package/src/commands/domain/install.ts +19 -11
  19. package/src/commands/get.ts +1 -1
  20. package/src/commands/identity/register.ts +3 -4
  21. package/src/commands/logs.ts +2 -5
  22. package/src/commands/session/analyze.ts +26 -4
  23. package/src/connection/.spec/architecture.md +9 -1
  24. package/src/connection/__tests__/auth.test.ts +1 -0
  25. package/src/connection/__tests__/credential.test.ts +1 -0
  26. package/src/connection/__tests__/exchange.test.ts +34 -10
  27. package/src/connection/__tests__/session.test.ts +5 -1
  28. package/src/connection/__tests__/target.test.ts +1 -0
  29. package/src/connection/exchange.ts +63 -38
  30. package/src/connection/session.ts +8 -1
  31. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  32. package/src/identity/__tests__/registry.test.ts +4 -0
  33. package/src/identity/registry.ts +2 -0
  34. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  35. package/src/lib/__tests__/config.test.ts +27 -0
  36. package/src/lib/__tests__/skills.test.ts +8 -2
  37. package/src/lib/browser-retention.ts +210 -0
  38. package/src/lib/config.ts +12 -1
  39. package/src/program/__tests__/program.test.ts +1 -1
  40. package/src/state/.spec/api.d.ts +21 -2
  41. package/src/state/.spec/architecture.md +18 -4
  42. package/src/state/.spec/layout.ts +1 -0
  43. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  44. package/src/state/__tests__/files.test.ts +24 -1
  45. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  46. package/src/state/__tests__/paths.test.ts +1 -0
  47. package/src/state/__tests__/session-routes.test.ts +138 -0
  48. package/src/state/exchange-credentials.ts +22 -9
  49. package/src/state/files.ts +41 -0
  50. package/src/state/index.ts +3 -1
  51. package/src/state/paths.ts +3 -0
  52. package/src/state/session-routes.ts +34 -0
  53. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  54. package/src/telemetry/__tests__/retention.test.ts +267 -0
  55. package/src/telemetry/__tests__/settings.test.ts +102 -0
  56. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  57. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  58. package/src/telemetry/analyze.ts +24 -2
  59. package/src/telemetry/recorder.ts +6 -3
  60. package/src/telemetry/retention.ts +176 -0
  61. package/src/telemetry/session.ts +18 -12
  62. package/src/telemetry/settings.ts +64 -11
  63. package/src/telemetry/store.ts +92 -9
  64. package/src/telemetry/trigger.ts +16 -28
  65. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  66. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  67. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  68. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  69. package/studio/client/dist/index.html +3 -3
  70. package/studio/package.json +1 -1
  71. package/studio/server/agent/prompts/anchors.test.ts +17 -4
  72. package/studio/server/agent/prompts/anchors.ts +3 -2
  73. package/studio/server/agent/prompts/system.test.ts +2 -3
  74. package/studio/server/agent/prompts/system.ts +3 -3
  75. package/studio/server/agent/run/preparation.ts +1 -1
  76. package/studio/server/api/context.ts +1 -1
  77. package/studio/server/api/deployment.ts +1 -1
  78. package/studio/server/api/views.ts +2 -2
  79. package/studio/server/api/workspace.ts +1 -1
  80. package/studio/server/cache.test.ts +3 -8
  81. package/studio/server/cache.ts +4 -45
  82. package/studio/server/handoff/copy.ts +1 -1
  83. package/studio/server/introspect/canonical-schema.test.ts +154 -128
  84. package/studio/server/introspect/canonical-schema.ts +183 -302
  85. package/studio/server/introspect/core.ts +14 -21
  86. package/studio/server/introspect/extractor.ts +11 -15
  87. package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
  88. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  89. package/studio/server/introspect/overlay.ts +3 -24
  90. package/studio/server/introspect/revision.test.ts +24 -28
  91. package/studio/server/introspect/revision.ts +10 -21
  92. package/studio/server/introspect/runtime.test.ts +14 -14
  93. package/studio/server/introspect/runtime.ts +1 -46
  94. package/studio/server/lifecycle.ts +4 -1
  95. package/studio/server/state/documents.test.ts +69 -0
  96. package/studio/server/state/documents.ts +57 -10
  97. package/studio/shared/contracts/schema.ts +10 -26
  98. package/studio/shared/contracts/surface.test.ts +2 -2
  99. package/studio/shared/contracts/workspace.ts +3 -0
  100. package/studio/shared/schema/identity.ts +0 -1
  101. package/viewer/dist/main.js +28 -28
  102. package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
  103. package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
  104. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
  105. package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
  106. package/studio/server/introspect/core-extractor.ts +0 -30
  107. package/studio/server/introspect/overlay.test.ts +0 -44
  108. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -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
  })
@@ -31,7 +31,7 @@ afterEach(async () => {
31
31
  })
32
32
 
33
33
  async function makeSource(
34
- names = ['astrale-cli', 'astrale-domain', 'astrale-services'],
34
+ names = ['astrale-cli', 'astrale-domain', 'astrale-frontend-design', 'astrale-services'],
35
35
  revision = 'new',
36
36
  ): Promise<{ root: string; snapshot: AstraleSkillSourceSnapshot }> {
37
37
  const root = await mkdtemp(join(tmpdir(), 'astrale-skills-source-'))
@@ -566,7 +566,13 @@ describe('Astrale skill reconciliation', () => {
566
566
 
567
567
  test('a retired source skill is removed without touching unrelated skills', async () => {
568
568
  const oldSource = await makeSource(
569
- ['astrale-cli', 'astrale-domain', 'astrale-services', 'astrale-retired'],
569
+ [
570
+ 'astrale-cli',
571
+ 'astrale-domain',
572
+ 'astrale-frontend-design',
573
+ 'astrale-services',
574
+ 'astrale-retired',
575
+ ],
570
576
  'old',
571
577
  )
572
578
  const latest = await makeSource(undefined, 'latest')
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Browser-profile retention.
3
+ *
4
+ * `astrale browser` keeps one persistent Chromium profile per host so the
5
+ * WorkOS cookie survives between runs. That cookie is a few kilobytes;
6
+ * everything Chromium accumulates around it is cache, and it accumulates
7
+ * without limit — a real profile here reached 373 MB, 98% of it cache.
8
+ *
9
+ * Chromium will not bound it for us. `--disk-cache-size` is measurably ignored
10
+ * for a profile's HTTP cache — the largest component by far — so deleting is
11
+ * the only lever. Two rules, cheapest first:
12
+ *
13
+ * 1. A profile untouched for longer than the age bound goes entirely. Its
14
+ * WorkOS cookie has expired anyway, so it holds nothing worth the disk.
15
+ * 2. A surviving profile whose cache exceeds the size bound has its cache
16
+ * directories removed. Cookies, Local Storage, Preferences and Local State
17
+ * are never touched — the whole point of the profile is that the session
18
+ * outlives the sweep.
19
+ *
20
+ * A profile whose SingletonLock names a live process is always left alone:
21
+ * deleting files under a running Chromium is how profiles get corrupted.
22
+ */
23
+ import { readlinkSync } from 'node:fs'
24
+ import { readdir, rm, stat } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+
27
+ import { BROWSER_DIR } from './browser'
28
+ import { readConfig } from './config'
29
+
30
+ /** Reconstructible directories, relative to a profile root. Everything here can
31
+ * be deleted with no user-visible consequence beyond a colder first load. */
32
+ const CACHE_PATHS = [
33
+ 'Default/Cache',
34
+ 'Default/Code Cache',
35
+ 'Default/GPUCache',
36
+ 'Default/DawnWebGPUCache',
37
+ 'Default/DawnGraphiteCache',
38
+ 'Default/Service Worker/CacheStorage',
39
+ 'Default/Service Worker/ScriptCache',
40
+ 'GraphiteDawnCache',
41
+ 'GPUPersistentCache',
42
+ 'GrShaderCache',
43
+ 'ShaderCache',
44
+ 'component_crx_cache',
45
+ 'extensions_crx_cache',
46
+ ] as const
47
+
48
+ export const DEFAULT_MAX_CACHE_BYTES = 50 * 1024 * 1024
49
+ export const DEFAULT_MAX_PROFILE_AGE_DAYS = 30
50
+
51
+ export type BrowserRetentionBudget = {
52
+ /** Cap on one profile's cache directories, not on the profile as a whole. */
53
+ maxCacheBytes: number
54
+ maxProfileAgeMs: number
55
+ }
56
+
57
+ export type BrowserSweepResult = {
58
+ /** Profiles deleted outright (dormant past the age bound). */
59
+ removed: string[]
60
+ /** Profiles kept, cache emptied (over the size bound). */
61
+ purged: string[]
62
+ /** Profiles a live browser was holding — left untouched. */
63
+ skipped: string[]
64
+ bytesFreed: number
65
+ }
66
+
67
+ const EMPTY: BrowserSweepResult = { removed: [], purged: [], skipped: [], bytesFreed: 0 }
68
+
69
+ /** First finite, strictly positive candidate; anything else falls through to
70
+ * the next one, and ultimately to the default. A typo must not mean "no cap". */
71
+ function firstPositive(candidates: (number | string | undefined)[]): number | null {
72
+ for (const candidate of candidates) {
73
+ if (candidate === undefined) continue
74
+ const n = typeof candidate === 'string' ? Number(candidate.trim()) : candidate
75
+ if (Number.isFinite(n) && n > 0) return n
76
+ }
77
+ return null
78
+ }
79
+
80
+ /** Resolved budget: env over config over defaults. */
81
+ export async function browserRetentionBudget(): Promise<BrowserRetentionBudget> {
82
+ const browser = (await readConfig().catch(() => null))?.browser
83
+ const bytes =
84
+ firstPositive([process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES, browser?.maxCacheBytes]) ??
85
+ DEFAULT_MAX_CACHE_BYTES
86
+ const days =
87
+ firstPositive([process.env.ASTRALE_BROWSER_MAX_PROFILE_AGE_DAYS, browser?.maxProfileAgeDays]) ??
88
+ DEFAULT_MAX_PROFILE_AGE_DAYS
89
+ return { maxCacheBytes: bytes, maxProfileAgeMs: days * 24 * 60 * 60 * 1000 }
90
+ }
91
+
92
+ /** True when a process with this pid exists. EPERM means it exists but is not
93
+ * ours — still alive, and still a reason not to touch the profile. */
94
+ function isLive(pid: number): boolean {
95
+ try {
96
+ process.kill(pid, 0)
97
+ return true
98
+ } catch (e) {
99
+ return (e as NodeJS.ErrnoException).code === 'EPERM'
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Whether a live browser holds this profile. SingletonLock is a symlink whose
105
+ * target is `<hostname>-<pid>` and which does NOT resolve to a real file, so it
106
+ * must be read with readlink, never stat'ed. A stale lock (dead pid) is not a
107
+ * reason to skip — Chromium leaves those behind after a crash.
108
+ */
109
+ export function heldByLiveBrowser(profileDir: string): boolean {
110
+ let target: string
111
+ try {
112
+ target = readlinkSync(join(profileDir, 'SingletonLock'))
113
+ } catch {
114
+ return false
115
+ }
116
+ const pid = Number(target.slice(target.lastIndexOf('-') + 1))
117
+ return Number.isInteger(pid) && pid > 0 && isLive(pid)
118
+ }
119
+
120
+ /** Bytes under `dir`, or 0 when it is missing or unreadable. */
121
+ async function directoryBytes(dir: string): Promise<number> {
122
+ let entries
123
+ try {
124
+ entries = await readdir(dir, { withFileTypes: true })
125
+ } catch {
126
+ return 0
127
+ }
128
+ let total = 0
129
+ for (const entry of entries) {
130
+ const path = join(dir, entry.name)
131
+ if (entry.isDirectory()) {
132
+ total += await directoryBytes(path)
133
+ } else if (entry.isFile()) {
134
+ try {
135
+ total += (await stat(path)).size
136
+ } catch {
137
+ /* raced away mid-walk */
138
+ }
139
+ }
140
+ }
141
+ return total
142
+ }
143
+
144
+ /** Cumulative size of one profile's cache directories. */
145
+ export async function profileCacheBytes(profileDir: string): Promise<number> {
146
+ let total = 0
147
+ for (const relative of CACHE_PATHS) total += await directoryBytes(join(profileDir, relative))
148
+ return total
149
+ }
150
+
151
+ /** Delete every cache directory of a profile; returns bytes actually freed. */
152
+ async function purgeCache(profileDir: string): Promise<number> {
153
+ const before = await profileCacheBytes(profileDir)
154
+ for (const relative of CACHE_PATHS) {
155
+ await rm(join(profileDir, relative), { recursive: true, force: true }).catch(() => {
156
+ /* best effort — retention must never break the browser command */
157
+ })
158
+ }
159
+ return before - (await profileCacheBytes(profileDir))
160
+ }
161
+
162
+ /**
163
+ * Apply both rules across every profile under `dir`. Never throws: a failed
164
+ * sweep must not stop the user from driving a browser.
165
+ */
166
+ export async function sweepBrowserProfiles(
167
+ options: { dir?: string; budget?: BrowserRetentionBudget; now?: number } = {},
168
+ ): Promise<BrowserSweepResult> {
169
+ const dir = options.dir ?? BROWSER_DIR
170
+ let names: string[]
171
+ try {
172
+ names = (await readdir(dir, { withFileTypes: true }))
173
+ .filter((e) => e.isDirectory())
174
+ .map((e) => e.name)
175
+ } catch {
176
+ return EMPTY
177
+ }
178
+ if (names.length === 0) return EMPTY
179
+
180
+ const budget = options.budget ?? (await browserRetentionBudget())
181
+ const now = options.now ?? Date.now()
182
+ const result: BrowserSweepResult = { removed: [], purged: [], skipped: [], bytesFreed: 0 }
183
+
184
+ for (const name of names) {
185
+ const profileDir = join(dir, name)
186
+ if (heldByLiveBrowser(profileDir)) {
187
+ result.skipped.push(name)
188
+ continue
189
+ }
190
+ try {
191
+ // The profile root's mtime tracks USE, not writes: Chromium rewrites
192
+ // SingletonLock and DevToolsActivePort there on every launch, while cache
193
+ // writes land in subdirectories and leave the root alone.
194
+ const idleMs = now - (await stat(profileDir)).mtime.getTime()
195
+ if (idleMs > budget.maxProfileAgeMs) {
196
+ result.bytesFreed += await directoryBytes(profileDir)
197
+ await rm(profileDir, { recursive: true, force: true })
198
+ result.removed.push(name)
199
+ continue
200
+ }
201
+ if ((await profileCacheBytes(profileDir)) > budget.maxCacheBytes) {
202
+ result.bytesFreed += await purgeCache(profileDir)
203
+ result.purged.push(name)
204
+ }
205
+ } catch {
206
+ /* best effort, per profile — one bad profile must not stop the sweep */
207
+ }
208
+ }
209
+ return result
210
+ }
package/src/lib/config.ts CHANGED
@@ -6,10 +6,21 @@ import { CONFIG_PATH } from '../state/index'
6
6
  import { AdminTargetConfigSchema, DEFAULT_ADMIN_TARGET_CONFIG } from './admin-target'
7
7
  import { log } from './log'
8
8
 
9
+ /** A retention bound, or undefined when absent or nonsensical. A bad value must
10
+ * fall back to the default rather than take the whole config down with it —
11
+ * and must never read as "no limit". */
12
+ const bound = z.number().positive().finite().optional().catch(undefined)
13
+
9
14
  export const AstraleConfigSchema = z.object({
10
15
  issuer: z.string().url().default('https://unregistered.invalid'),
11
16
  admin: AdminTargetConfigSchema.default(DEFAULT_ADMIN_TARGET_CONFIG),
12
- telemetry: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
17
+ // The retention bounds live in the schema, not just in the readers that
18
+ // consume them: zod strips unknown keys, so a config the CLI rewrites (see
19
+ // setup/steps/admin.ts) would silently drop anything declared elsewhere.
20
+ telemetry: z
21
+ .object({ enabled: z.boolean().default(true), maxAgeDays: bound, maxBytes: bound })
22
+ .default({ enabled: true }),
23
+ browser: z.object({ maxCacheBytes: bound, maxProfileAgeDays: bound }).default({}),
13
24
  })
14
25
 
15
26
  export type AstraleConfig = z.infer<typeof AstraleConfigSchema>
@@ -195,7 +195,7 @@ describe('program composition', () => {
195
195
  'whoami',
196
196
  ])
197
197
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
198
- '2e26ce7e4af960b3ec51401e2c439d1d54c13e06de7afda707aa65e8984bb37b',
198
+ '092b8f4f737740105c8baed9d01a81604711b5b2719dfcb834eeeac414732c61',
199
199
  )
200
200
  })
201
201
 
@@ -1,3 +1,5 @@
1
+ import type { SessionRouteArtifact, SessionRouteStore } from '@astrale-os/sdk/client/session'
2
+
1
3
  /** Environment values that affect CLI-owned state placement. */
2
4
  export interface PathEnvironment {
3
5
  readonly ASTRALE_HOME?: string
@@ -17,6 +19,7 @@ export interface Paths {
17
19
  readonly idps: string
18
20
  readonly idpSessionsDir: string
19
21
  readonly exchangeCredentials: string
22
+ readonly sessionRoutes: string
20
23
  idpDir(name: string): string
21
24
  idpSession(identityName: string): string
22
25
  }
@@ -35,22 +38,27 @@ export const INSTANCES_PATH: string
35
38
  export const IDPS_PATH: string
36
39
  export const IDP_SESSIONS_DIR: string
37
40
  export const EXCHANGE_CREDENTIALS_PATH: string
41
+ export const SESSION_ROUTES_PATH: string
38
42
 
39
43
  export namespace exchange {
40
44
  interface Artifact {
41
- readonly version: 1
45
+ readonly version: 2
42
46
  readonly entries: Record<string, Entry>
43
47
  }
44
48
 
45
49
  interface Key {
46
50
  readonly kernelIssuer: string
47
51
  readonly domainIssuer: string
48
- readonly user: string
52
+ readonly sourceIssuer: string
53
+ readonly sourceSubject: string
49
54
  }
50
55
 
51
56
  interface Entry {
52
57
  readonly credential: string
53
58
  readonly expiresAt: number
59
+ readonly user: string
60
+ readonly sourceIssuer: string
61
+ readonly sourceSubject: string
54
62
  }
55
63
  }
56
64
 
@@ -118,6 +126,17 @@ export function updateIdentityStore<Value>(
118
126
 
119
127
  /** Atomically replace one private CLI state file through a same-directory temporary file. */
120
128
  export function atomicWrite(path: string, data: string): Promise<void>
129
+ export function atomicWriteSync(path: string, data: string): void
130
+
131
+ /** CLI filesystem representation for Kernel Client's admitted confidential route artifact. */
132
+ export class FileSessionRouteStore implements SessionRouteStore {
133
+ constructor(path?: string)
134
+ read(): unknown
135
+ write(artifact: SessionRouteArtifact): void
136
+ clear(): void
137
+ }
138
+
139
+ export const SESSION_ROUTE_STORE: Readonly<FileSessionRouteStore>
121
140
 
122
141
  export interface FileLockOptions {
123
142
  readonly pollIntervalMs?: number
@@ -11,10 +11,24 @@ The identity registry decodes the legacy unversioned shape and current V1 envelo
11
11
  exact bytes at `identities.json.v0.bak` before publishing V1. Invalid or newer files remain untouched
12
12
  and fail closed. Identity orchestration owns key and IdP-session effects above this module.
13
13
 
14
- The exchange registry owns a versioned `exchange.Artifact`. Each entry is keyed by the atomic
15
- `(Kernel issuer, Domain issuer, registered User)` identity and stores only one inspected Domain
16
- credential and expiry. It reuses the same atomic-write and bounded file-lock primitives, maintains
17
- owner-only directory and file modes, and remains distinct from Client's process-local route cache.
14
+ The exchange registry owns a versioned `exchange.Artifact`. Each V2 entry is keyed by the atomic
15
+ `(Kernel issuer, Domain issuer, source issuer, source subject)` identity and stores one inspected
16
+ Domain credential, expiry, and the exact registered Kernel User proven when that credential was
17
+ minted, together with the exact upstream source issuer and subject that selected the entry. This lets
18
+ a warm command validate and reuse the credential before any network `whoami` without accepting a
19
+ refresh result filed under another source identity.
20
+ V1 is not migrated or retained; it is discarded and replaced by the next exact exchange. The
21
+ registry reuses the same atomic-write and bounded file-lock primitives and maintains owner-only
22
+ directory and file modes.
23
+
24
+ The session-route registry is a separate representation adapter for Kernel Client's versioned,
25
+ bounded confidential route artifact. Kernel Client exclusively owns route keying, admission, expiry,
26
+ and stale/miss recovery. CLI owns only synchronous owner-private JSON I/O so a new CLI process can
27
+ reuse admitted routing state without an Admin-specific shortcut. Writes are atomic snapshots; a
28
+ lost concurrent cache update or any read/write failure is only a future cold route miss and cannot
29
+ change product correctness.
30
+ Authentication logout and identity deletion remove this artifact together with exchanged Domain
31
+ credentials so locally retired authority does not leave reusable destination bearers behind.
18
32
 
19
33
  Shape decoding, migrations, and backup policy remain with each semantic registry. Commands do not
20
34
  call these primitives directly, and Kernel Client owns no CLI filesystem state.
@@ -8,6 +8,7 @@ export default defineLayout({
8
8
  'identities.ts',
9
9
  'index.ts',
10
10
  'paths.ts',
11
+ 'session-routes.ts',
11
12
  'tsconfig.json',
12
13
  ],
13
14
  exact: true,