@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.
- package/README.md +59 -3
- package/dist/astrale.js +1765 -1057
- package/dist/public/connect-core.js +86 -17
- package/dist/public/keys/index.js +69 -2
- package/dist/public/paths/index.js +67 -0
- package/dist/types/connection/session.d.ts +2 -2
- package/dist/types/lib/config.d.ts +6 -0
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +2 -2
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
- package/src/commands/__tests__/install-identity-override.test.ts +3 -3
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/commands/domain/install.ts +19 -11
- package/src/commands/get.ts +1 -1
- package/src/commands/identity/register.ts +3 -4
- package/src/commands/logs.ts +2 -5
- package/src/commands/session/analyze.ts +26 -4
- package/src/connection/.spec/architecture.md +9 -1
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +1 -0
- package/src/connection/__tests__/exchange.test.ts +34 -10
- package/src/connection/__tests__/session.test.ts +5 -1
- package/src/connection/__tests__/target.test.ts +1 -0
- package/src/connection/exchange.ts +63 -38
- package/src/connection/session.ts +8 -1
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registry.ts +2 -0
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/__tests__/skills.test.ts +8 -2
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/config.ts +12 -1
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/retention.test.ts +267 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/recorder.ts +6 -3
- package/src/telemetry/retention.ts +176 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +92 -9
- package/src/telemetry/trigger.ts +16 -28
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +3 -3
- package/studio/package.json +1 -1
- package/studio/server/agent/prompts/anchors.test.ts +17 -4
- package/studio/server/agent/prompts/anchors.ts +3 -2
- package/studio/server/agent/prompts/system.test.ts +2 -3
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/cache.test.ts +3 -8
- package/studio/server/cache.ts +4 -45
- package/studio/server/handoff/copy.ts +1 -1
- package/studio/server/introspect/canonical-schema.test.ts +154 -128
- package/studio/server/introspect/canonical-schema.ts +183 -302
- package/studio/server/introspect/core.ts +14 -21
- package/studio/server/introspect/extractor.ts +11 -15
- package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -24
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +10 -21
- package/studio/server/introspect/runtime.test.ts +14 -14
- package/studio/server/introspect/runtime.ts +1 -46
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/shared/contracts/schema.ts +10 -26
- package/studio/shared/contracts/surface.test.ts +2 -2
- package/studio/shared/contracts/workspace.ts +3 -0
- package/studio/shared/schema/identity.ts +0 -1
- package/viewer/dist/main.js +28 -28
- package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
- package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
- package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
- package/studio/server/introspect/core-extractor.ts +0 -30
- package/studio/server/introspect/overlay.test.ts +0 -44
- 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
|
-
[
|
|
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
|
-
|
|
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
|
-
'
|
|
198
|
+
'092b8f4f737740105c8baed9d01a81604711b5b2719dfcb834eeeac414732c61',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|
package/src/state/.spec/api.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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,
|
|
16
|
-
credential and
|
|
17
|
-
|
|
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.
|