@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
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
- import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
@@ -19,11 +19,22 @@ afterEach(async () => {
19
19
 
20
20
  describe('exchange credential cache', () => {
21
21
  /** @evidence TEST-CLI-EXCHANGE-CACHE-EXACT-KEY-AND-PRIVATE-MODE */
22
- test('partitions by Kernel, Domain, and User under private filesystem modes', async () => {
22
+ test('partitions by Kernel, Domain, and source identity under private filesystem modes', async () => {
23
23
  const cache = new ExchangeCredentialCache(path)
24
24
  const now = () => 100
25
25
  const first = key('https://kernel-a.example', 'https://domain.example', 'user-a')
26
- const second = key('https://kernel-a.example', 'https://domain.example', 'user-b')
26
+ const partitions = [
27
+ first,
28
+ key('https://kernel-b.example', first.domainIssuer, first.sourceSubject),
29
+ key(first.kernelIssuer, 'https://other-domain.example', first.sourceSubject),
30
+ key(
31
+ first.kernelIssuer,
32
+ first.domainIssuer,
33
+ first.sourceSubject,
34
+ 'https://other-source.example',
35
+ ),
36
+ key(first.kernelIssuer, first.domainIssuer, 'user-b'),
37
+ ]
27
38
  let refreshes = 0
28
39
 
29
40
  const resolve = (candidate: typeof first) =>
@@ -31,18 +42,16 @@ describe('exchange credential cache', () => {
31
42
  candidate,
32
43
  async () => {
33
44
  refreshes += 1
34
- return {
35
- credential: token(candidate, 200),
36
- expiresAt: 200,
37
- }
45
+ return entry(candidate, 200)
38
46
  },
39
47
  now,
40
48
  )
41
49
 
42
- await expect(resolve(first)).resolves.toBe(token(first, 200))
43
- await expect(resolve(first)).resolves.toBe(token(first, 200))
44
- await expect(resolve(second)).resolves.toBe(token(second, 200))
45
- expect(refreshes).toBe(2)
50
+ for (const candidate of partitions) {
51
+ await expect(resolve(candidate)).resolves.toBe(token(candidate, 200))
52
+ await expect(resolve(candidate)).resolves.toBe(token(candidate, 200))
53
+ }
54
+ expect(refreshes).toBe(partitions.length)
46
55
  expect((await stat(path)).mode & 0o777).toBe(0o600)
47
56
  expect((await stat(join(directory, 'private'))).mode & 0o777).toBe(0o700)
48
57
  })
@@ -56,10 +65,7 @@ describe('exchange credential cache', () => {
56
65
  const refresh = async () => {
57
66
  refreshes += 1
58
67
  await new Promise((resolve) => setTimeout(resolve, 10))
59
- return {
60
- credential: token(candidate, 200),
61
- expiresAt: 200,
62
- }
68
+ return entry(candidate, 200)
63
69
  }
64
70
 
65
71
  const values = await Promise.all([
@@ -77,10 +83,7 @@ describe('exchange credential cache', () => {
77
83
  const candidate = key('https://kernel.example', 'https://domain.example', 'user')
78
84
  await cache.getOrRefresh(
79
85
  candidate,
80
- async () => ({
81
- credential: token(candidate, 120),
82
- expiresAt: 120,
83
- }),
86
+ async () => entry(candidate, 120),
84
87
  () => 50,
85
88
  )
86
89
  let refreshes = 0
@@ -88,10 +91,7 @@ describe('exchange credential cache', () => {
88
91
  candidate,
89
92
  async () => {
90
93
  refreshes += 1
91
- return {
92
- credential: token(candidate, 220),
93
- expiresAt: 220,
94
- }
94
+ return entry(candidate, 220)
95
95
  },
96
96
  () => 100,
97
97
  )
@@ -99,19 +99,16 @@ describe('exchange credential cache', () => {
99
99
 
100
100
  await expect(
101
101
  cache.getOrRefresh(
102
- key('https://other-kernel.example', candidate.domainIssuer, candidate.user),
103
- async () => ({
104
- credential: token(candidate, 220),
105
- expiresAt: 220,
106
- }),
102
+ key('https://other-kernel.example', candidate.domainIssuer, candidate.sourceSubject),
103
+ async () => entry(candidate, 220),
107
104
  () => 100,
108
105
  ),
109
106
  ).rejects.toThrow(/inconsistent with its cache key/i)
110
107
 
111
108
  await expect(
112
109
  cache.getOrRefresh(
113
- key(candidate.kernelIssuer, candidate.domainIssuer, 'other-user'),
114
- async () => ({ credential: token(candidate, 220), expiresAt: 220 }),
110
+ key(candidate.kernelIssuer, candidate.domainIssuer, 'other-source'),
111
+ async () => entry(candidate, 220),
115
112
  () => 100,
116
113
  ),
117
114
  ).rejects.toThrow(/inconsistent with its cache key/i)
@@ -125,10 +122,7 @@ describe('exchange credential cache', () => {
125
122
  await expect(
126
123
  cache.getOrRefresh(
127
124
  malformedCandidate,
128
- async () => ({
129
- credential: token(malformedCandidate, 220, malformed),
130
- expiresAt: 220,
131
- }),
125
+ async () => entry(malformedCandidate, 220, malformed),
132
126
  () => 100,
133
127
  ),
134
128
  ).rejects.toThrow(/inconsistent with its cache key/i)
@@ -143,10 +137,7 @@ describe('exchange credential cache', () => {
143
137
  for (const candidate of [a, b]) {
144
138
  await cache.getOrRefresh(
145
139
  candidate,
146
- async () => ({
147
- credential: token(candidate, 200),
148
- expiresAt: 200,
149
- }),
140
+ async () => entry(candidate, 200),
150
141
  () => 100,
151
142
  )
152
143
  }
@@ -156,10 +147,60 @@ describe('exchange credential cache', () => {
156
147
  await cache.clear()
157
148
  expect(JSON.parse(await readFile(path, 'utf8')).entries).toEqual({})
158
149
  })
150
+
151
+ test('discards the V1 cache and writes only V2 after a fresh exact exchange', async () => {
152
+ await mkdir(join(directory, 'private'))
153
+ await writeFile(
154
+ path,
155
+ JSON.stringify({
156
+ version: 1,
157
+ entries: { legacy: { credential: 'legacy', expiresAt: 999 } },
158
+ }),
159
+ )
160
+ const cache = new ExchangeCredentialCache(path)
161
+ const candidate = key('https://kernel.example', 'https://domain.example', 'source-user')
162
+ let refreshes = 0
163
+ await cache.getOrRefresh(
164
+ candidate,
165
+ async () => {
166
+ refreshes += 1
167
+ return entry(candidate, 200)
168
+ },
169
+ () => 100,
170
+ )
171
+ expect(refreshes).toBe(1)
172
+ const stored = JSON.parse(await readFile(path, 'utf8'))
173
+ expect(stored.version).toBe(2)
174
+ expect(JSON.stringify(stored)).not.toContain('legacy')
175
+ })
159
176
  })
160
177
 
161
- function key(kernelIssuer: string, domainIssuer: string, user: string) {
162
- return { kernelIssuer, domainIssuer, user }
178
+ function key(
179
+ kernelIssuer: string,
180
+ domainIssuer: string,
181
+ sourceSubject: string,
182
+ sourceIssuer = 'https://source.example',
183
+ ) {
184
+ return {
185
+ kernelIssuer,
186
+ domainIssuer,
187
+ sourceIssuer,
188
+ sourceSubject,
189
+ }
190
+ }
191
+
192
+ function entry(
193
+ candidate: ReturnType<typeof key>,
194
+ expiresAt: number,
195
+ malformed?: 'outer-delegation' | 'proof-without-delegation',
196
+ ) {
197
+ return {
198
+ credential: token(candidate, expiresAt, malformed),
199
+ expiresAt,
200
+ user: candidate.sourceSubject,
201
+ sourceIssuer: candidate.sourceIssuer,
202
+ sourceSubject: candidate.sourceSubject,
203
+ }
163
204
  }
164
205
 
165
206
  function token(
@@ -170,12 +211,17 @@ function token(
170
211
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
171
212
  const proof = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
172
213
  iss: candidate.kernelIssuer,
173
- sub: candidate.user,
214
+ sub: candidate.sourceSubject,
174
215
  aud: candidate.kernelIssuer,
175
216
  exp,
176
217
  ...(malformed === 'proof-without-delegation'
177
218
  ? {}
178
- : { delegation: { v: 1, expr: { kind: 'identity', id: candidate.user } } }),
219
+ : {
220
+ delegation: {
221
+ v: 1,
222
+ expr: { kind: 'identity', id: candidate.sourceSubject },
223
+ },
224
+ }),
179
225
  })}.signature`
180
226
  return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
181
227
  iss: candidate.domainIssuer,
@@ -13,7 +13,7 @@ import {
13
13
  import { tmpdir } from 'node:os'
14
14
  import { join } from 'node:path'
15
15
 
16
- import { atomicWrite, withFileLock } from '../files'
16
+ import { atomicWrite, atomicWriteSync, withFileLock } from '../files'
17
17
 
18
18
  let temporaryDirectory: string
19
19
 
@@ -50,6 +50,29 @@ describe('atomicWrite', () => {
50
50
  })
51
51
  })
52
52
 
53
+ describe('atomicWriteSync', () => {
54
+ test('publishes a synced mode-0600 file without temporary residue', async () => {
55
+ const path = join(temporaryDirectory, 'sync', 'state.json')
56
+
57
+ atomicWriteSync(path, '{"value":1}')
58
+
59
+ expect(await readFile(path, 'utf-8')).toBe('{"value":1}')
60
+ expect((await stat(path)).mode & 0o777).toBe(0o600)
61
+ expect(await readdir(join(temporaryDirectory, 'sync'))).toEqual(['state.json'])
62
+ })
63
+
64
+ test('does not disturb the target or retain a temporary file after failure', async () => {
65
+ const path = join(temporaryDirectory, 'sync-target')
66
+ await mkdir(path)
67
+ await writeFile(join(path, 'retained'), 'stable')
68
+
69
+ expect(() => atomicWriteSync(path, 'replacement')).toThrow()
70
+
71
+ expect(await readFile(join(path, 'retained'), 'utf-8')).toBe('stable')
72
+ expect((await readdir(temporaryDirectory)).sort()).toEqual(['sync-target'])
73
+ })
74
+ })
75
+
53
76
  describe('withFileLock', () => {
54
77
  /** @evidence TEST-CLI-STATE-LOCK-SERIALIZES */
55
78
  test('serializes contending read-modify-write transitions', async () => {
@@ -0,0 +1,93 @@
1
+ import type { Transport } from '@astrale-os/sdk/client'
2
+
3
+ import { issuer } from '@astrale-os/sdk/auth'
4
+ import { call, Client } from '@astrale-os/sdk/client'
5
+ import { ClientSession } from '@astrale-os/sdk/client/session'
6
+ import { NodeId } from '@astrale-os/sdk/graph/node'
7
+ import { Path } from '@astrale-os/sdk/graph/path'
8
+ import { invocation } from '@astrale-os/sdk/invocation'
9
+
10
+ import { FileSessionRouteStore } from '../../session-routes'
11
+
12
+ const routePath = process.argv[2]
13
+ if (routePath === undefined) throw new Error('route artifact path is required')
14
+
15
+ const sourceEndpoint = 'https://source.kernel.test/invoke'
16
+ const sourceIssuer = issuer.accept('https://source.kernel.test')
17
+ const destinationEndpoint = 'https://destination.application.test/invoke'
18
+ const target = Path.id(NodeId('session-route-fresh-process'))
19
+ let sourceAttempts = 0
20
+ let destinationAttempts = 0
21
+
22
+ const route = invocation.acceptRoute({
23
+ endpoint: { http: destinationEndpoint },
24
+ via: {
25
+ kind: 'via',
26
+ issuer: sourceIssuer,
27
+ publication: {
28
+ origin: 'destination.application.test',
29
+ identity: {
30
+ issuer: 'https://destination.application.test',
31
+ subject: 'destination.application.test',
32
+ },
33
+ revision: `sha256:${'1'.padStart(64, '0')}`,
34
+ etag: `sha256:${'1'.padStart(64, '0')}`,
35
+ },
36
+ },
37
+ })
38
+ const carrier = `e30.${Buffer.from(
39
+ JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 600 }),
40
+ ).toString('base64url')}.signature`
41
+ const sourceDispatch: Transport['dispatch'] = async () => {
42
+ sourceAttempts += 1
43
+ return {
44
+ kind: 'redirect',
45
+ invocation: { source: sourceIssuer, id: 'source-invocation' },
46
+ redirect: { route, credential: carrier },
47
+ }
48
+ }
49
+ const destinationDispatch: Transport['dispatch'] = async () => {
50
+ destinationAttempts += 1
51
+ return {
52
+ kind: 'value',
53
+ value: 'done',
54
+ invocation: { source: sourceIssuer, id: 'destination-invocation' },
55
+ }
56
+ }
57
+ const source = new Client({ transport: { dispatch: sourceDispatch } })
58
+ const destination = new Client({ transport: { dispatch: destinationDispatch } })
59
+ const session = new ClientSession({
60
+ kernel: sourceIssuer,
61
+ fetch: async () =>
62
+ new Response(
63
+ JSON.stringify({
64
+ protocol: 'astrale-invocation',
65
+ version: 1,
66
+ issuer: sourceIssuer,
67
+ endpoints: { http: sourceEndpoint },
68
+ }),
69
+ { headers: { 'content-type': 'application/json' } },
70
+ ),
71
+ pool: {
72
+ clientFor(
73
+ target: Parameters<
74
+ NonNullable<ConstructorParameters<typeof ClientSession>[0]['pool']>['clientFor']
75
+ >[0],
76
+ ) {
77
+ const url = target.transport === 'auto' ? target.endpoints.http : target.url
78
+ return url === sourceEndpoint ? source : destination
79
+ },
80
+ },
81
+ auth: { ttlSeconds: 120, resolve: () => ({ credential: 'source-credential' }) },
82
+ routeStore: new FileSessionRouteStore(routePath),
83
+ policy: { maximumRouteAgeMs: 60_000 },
84
+ })
85
+
86
+ try {
87
+ const value = await session.call(call(target, null))
88
+ console.log(JSON.stringify({ value, sourceAttempts, destinationAttempts }))
89
+ } finally {
90
+ session.close()
91
+ source.close()
92
+ destination.close()
93
+ }
@@ -20,6 +20,7 @@ describe('state paths', () => {
20
20
  expect(explicit.home).toBe('/explicit/home')
21
21
  expect(explicit.keys).toBe('/environment/keys')
22
22
  expect(explicit.config).toBe(join('/explicit/home', 'config.json'))
23
+ expect(explicit.sessionRoutes).toBe(join('/explicit/home', 'session', 'routes.json'))
23
24
  expect(inherited.home).toBe('/environment/home')
24
25
  expect(inherited.keys).toBe('/environment/keys')
25
26
  expect(Object.isFrozen(explicit)).toBe(true)
@@ -0,0 +1,138 @@
1
+ import type { SessionRouteArtifact } from '@astrale-os/sdk/client/session'
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
4
+ import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import { FileSessionRouteStore } from '../session-routes'
9
+
10
+ let directory: string
11
+ let path: string
12
+
13
+ beforeEach(async () => {
14
+ directory = await mkdtemp(join(tmpdir(), 'astrale-session-routes-'))
15
+ path = join(directory, 'private', 'routes.json')
16
+ })
17
+
18
+ afterEach(async () => {
19
+ await rm(directory, { recursive: true, force: true })
20
+ })
21
+
22
+ describe('session route file store', () => {
23
+ test('round-trips the Kernel-owned artifact under owner-private filesystem modes', async () => {
24
+ const artifact: SessionRouteArtifact = { version: 1, entries: {} }
25
+ const store = new FileSessionRouteStore(path)
26
+
27
+ store.write(artifact)
28
+
29
+ expect(store.read()).toEqual(artifact)
30
+ expect((await stat(path)).mode & 0o777).toBe(0o600)
31
+ expect((await stat(join(directory, 'private'))).mode & 0o777).toBe(0o700)
32
+ expect(await readFile(path, 'utf8')).toBe('{"version":1,"entries":{}}\n')
33
+
34
+ store.clear()
35
+ await expect(access(path)).rejects.toThrow()
36
+ expect(() => store.clear()).not.toThrow()
37
+ })
38
+
39
+ test('leaves malformed representation recovery to Kernel Client as a cold miss', async () => {
40
+ await mkdir(join(directory, 'private'))
41
+ await writeFile(path, '{invalid')
42
+ const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
43
+
44
+ expect(await runFixture(fixture, path)).toEqual({
45
+ value: 'done',
46
+ sourceAttempts: 1,
47
+ destinationAttempts: 1,
48
+ })
49
+ expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ version: 1 })
50
+ })
51
+
52
+ test('reuses Kernel-admitted routing state in a separate operating-system process', async () => {
53
+ const routePath = join(directory, 'private', 'routes.json')
54
+ const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
55
+
56
+ const first = await runFixture(fixture, routePath)
57
+ const second = await runFixture(fixture, routePath)
58
+
59
+ expect(first).toEqual({ value: 'done', sourceAttempts: 1, destinationAttempts: 1 })
60
+ expect(second).toEqual({ value: 'done', sourceAttempts: 0, destinationAttempts: 1 })
61
+ })
62
+
63
+ test('publishes only complete owner-private artifacts under concurrent process writers', async () => {
64
+ const routePath = join(directory, 'private', 'routes.json')
65
+ const fixture = join(import.meta.dir, 'fixtures', 'session-route-process.ts')
66
+
67
+ const results = await Promise.all(
68
+ Array.from({ length: 8 }, () => runFixture(fixture, routePath)),
69
+ )
70
+ expect(results).toHaveLength(8)
71
+ expect(results.every((result) => result.destinationAttempts === 1)).toBe(true)
72
+ expect(JSON.parse(await readFile(routePath, 'utf8'))).toMatchObject({ version: 1 })
73
+ expect((await stat(routePath)).mode & 0o777).toBe(0o600)
74
+ expect(await runFixture(fixture, routePath)).toEqual({
75
+ value: 'done',
76
+ sourceAttempts: 0,
77
+ destinationAttempts: 1,
78
+ })
79
+ })
80
+
81
+ test('auth logout clears every persisted bearer cache through the real CLI command', async () => {
82
+ const routePath = join(directory, 'session', 'routes.json')
83
+ const exchangePath = join(directory, 'exchange', 'credentials.json')
84
+ await mkdir(join(directory, 'session'), { recursive: true })
85
+ await mkdir(join(directory, 'exchange'), { recursive: true })
86
+ await writeFile(routePath, '{"version":1,"entries":{"confidential":{}}}\n')
87
+ await writeFile(
88
+ exchangePath,
89
+ '{"version":2,"entries":{"confidential":{"credential":"bearer"}}}\n',
90
+ )
91
+
92
+ const child = Bun.spawn(
93
+ [
94
+ process.execPath,
95
+ join(import.meta.dir, '../../..', 'bin', 'astrale.ts'),
96
+ 'auth',
97
+ 'logout',
98
+ '--all',
99
+ '--json',
100
+ ],
101
+ {
102
+ env: { ...process.env, ASTRALE_HOME: directory },
103
+ stdout: 'pipe',
104
+ stderr: 'pipe',
105
+ },
106
+ )
107
+ const [exitCode, stdout, stderr] = await Promise.all([
108
+ child.exited,
109
+ new Response(child.stdout).text(),
110
+ new Response(child.stderr).text(),
111
+ ])
112
+
113
+ expect(exitCode, stderr).toBe(0)
114
+ expect(JSON.parse(stdout)).toEqual({ cleared: [] })
115
+ await expect(access(routePath)).rejects.toThrow()
116
+ expect(JSON.parse(await readFile(exchangePath, 'utf8'))).toEqual({ version: 2, entries: {} })
117
+ })
118
+ })
119
+
120
+ interface FixtureResult {
121
+ readonly value: string
122
+ readonly sourceAttempts: number
123
+ readonly destinationAttempts: number
124
+ }
125
+
126
+ async function runFixture(fixture: string, routePath: string): Promise<FixtureResult> {
127
+ const child = Bun.spawn([process.execPath, fixture, routePath], {
128
+ stdout: 'pipe',
129
+ stderr: 'pipe',
130
+ })
131
+ const [exitCode, stdout, stderr] = await Promise.all([
132
+ child.exited,
133
+ new Response(child.stdout).text(),
134
+ new Response(child.stderr).text(),
135
+ ])
136
+ expect(exitCode, stderr).toBe(0)
137
+ return JSON.parse(stdout) as FixtureResult
138
+ }
@@ -5,24 +5,28 @@ import { dirname } from 'node:path'
5
5
  import { atomicWrite, withFileLock } from './files'
6
6
  import { EXCHANGE_CREDENTIALS_PATH } from './paths'
7
7
 
8
- const VERSION = 1
8
+ const VERSION = 2
9
9
  const MINIMUM_REMAINING_SECONDS = 30
10
10
 
11
11
  export namespace exchange {
12
12
  export interface Artifact {
13
- readonly version: 1
13
+ readonly version: 2
14
14
  readonly entries: Record<string, Entry>
15
15
  }
16
16
 
17
17
  export interface Key {
18
18
  readonly kernelIssuer: string
19
19
  readonly domainIssuer: string
20
- readonly user: string
20
+ readonly sourceIssuer: string
21
+ readonly sourceSubject: string
21
22
  }
22
23
 
23
24
  export interface Entry {
24
25
  readonly credential: string
25
26
  readonly expiresAt: number
27
+ readonly user: string
28
+ readonly sourceIssuer: string
29
+ readonly sourceSubject: string
26
30
  }
27
31
  }
28
32
 
@@ -140,11 +144,15 @@ function validEntry(
140
144
  if (
141
145
  entry === null ||
142
146
  typeof entry !== 'object' ||
143
- Reflect.ownKeys(entry).length !== 2 ||
147
+ Reflect.ownKeys(entry).length !== 5 ||
144
148
  typeof entry.credential !== 'string' ||
145
149
  entry.credential.length === 0 ||
146
150
  !Number.isSafeInteger(entry.expiresAt) ||
147
- entry.expiresAt - now < minimumRemaining
151
+ entry.expiresAt - now < minimumRemaining ||
152
+ typeof entry.user !== 'string' ||
153
+ entry.user.length === 0 ||
154
+ entry.sourceIssuer !== key.sourceIssuer ||
155
+ entry.sourceSubject !== key.sourceSubject
148
156
  ) {
149
157
  return false
150
158
  }
@@ -178,7 +186,7 @@ function validEntry(
178
186
  inspected.claims.exp === entry.expiresAt &&
179
187
  !Object.hasOwn(inspected.claims, 'delegation') &&
180
188
  proof.iss === key.kernelIssuer &&
181
- proof.sub === key.user &&
189
+ proof.sub === entry.user &&
182
190
  proof.aud === key.kernelIssuer
183
191
  )
184
192
  } catch {
@@ -187,7 +195,7 @@ function validEntry(
187
195
  }
188
196
 
189
197
  function encodeKey(key: exchange.Key): string {
190
- return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.user])
198
+ return JSON.stringify([key.kernelIssuer, key.domainIssuer, key.sourceIssuer, key.sourceSubject])
191
199
  }
192
200
 
193
201
  function decodeKey(input: string): exchange.Key | undefined {
@@ -195,12 +203,17 @@ function decodeKey(input: string): exchange.Key | undefined {
195
203
  const value = JSON.parse(input) as unknown
196
204
  if (
197
205
  !Array.isArray(value) ||
198
- value.length !== 3 ||
206
+ value.length !== 4 ||
199
207
  value.some((part) => typeof part !== 'string' || part.length === 0)
200
208
  ) {
201
209
  return undefined
202
210
  }
203
- return { kernelIssuer: value[0]!, domainIssuer: value[1]!, user: value[2]! }
211
+ return {
212
+ kernelIssuer: value[0]!,
213
+ domainIssuer: value[1]!,
214
+ sourceIssuer: value[2]!,
215
+ sourceSubject: value[3]!,
216
+ }
204
217
  } catch {
205
218
  return undefined
206
219
  }
@@ -1,4 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import {
3
+ closeSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
2
11
  import { mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises'
3
12
  import { dirname } from 'node:path'
4
13
 
@@ -30,6 +39,38 @@ export async function atomicWrite(path: string, data: string): Promise<void> {
30
39
  }
31
40
  }
32
41
 
42
+ /** Atomically publish one complete private state file for synchronous consumer capabilities. */
43
+ export function atomicWriteSync(path: string, data: string): void {
44
+ const directory = dirname(path)
45
+ const temporary = `${path}.${randomUUID()}.tmp`
46
+ mkdirSync(directory, { recursive: true })
47
+
48
+ let descriptor: number | undefined
49
+ try {
50
+ descriptor = openSync(temporary, 'wx', 0o600)
51
+ writeFileSync(descriptor, data)
52
+ fsyncSync(descriptor)
53
+ closeSync(descriptor)
54
+ descriptor = undefined
55
+ renameSync(temporary, path)
56
+
57
+ const directoryDescriptor = openSync(directory, 'r')
58
+ try {
59
+ fsyncSync(directoryDescriptor)
60
+ } finally {
61
+ closeSync(directoryDescriptor)
62
+ }
63
+ } catch (error) {
64
+ if (descriptor !== undefined) closeSync(descriptor)
65
+ try {
66
+ unlinkSync(temporary)
67
+ } catch {
68
+ // The temporary file was either never created or was already renamed.
69
+ }
70
+ throw error
71
+ }
72
+ }
73
+
33
74
  export interface FileLockOptions {
34
75
  readonly pollIntervalMs?: number
35
76
  readonly staleAfterMs?: number
@@ -1,4 +1,4 @@
1
- export { atomicWrite, withFileLock } from './files'
1
+ export { atomicWrite, atomicWriteSync, withFileLock } from './files'
2
2
  export type { FileLockOptions } from './files'
3
3
  export { ExchangeCredentialCache } from './exchange-credentials'
4
4
  export type { exchange } from './exchange-credentials'
@@ -23,7 +23,9 @@ export {
23
23
  INSTALL_PATH,
24
24
  INSTANCES_PATH,
25
25
  KEYS_DIR,
26
+ SESSION_ROUTES_PATH,
26
27
  createPaths,
27
28
  paths,
28
29
  } from './paths'
29
30
  export type { PathEnvironment, Paths } from './paths'
31
+ export { FileSessionRouteStore, SESSION_ROUTE_STORE } from './session-routes'
@@ -18,6 +18,7 @@ export interface Paths {
18
18
  readonly idps: string
19
19
  readonly idpSessionsDir: string
20
20
  readonly exchangeCredentials: string
21
+ readonly sessionRoutes: string
21
22
  idpDir(name: string): string
22
23
  idpSession(identityName: string): string
23
24
  }
@@ -43,6 +44,7 @@ export function createPaths(home?: string, environment?: PathEnvironment): Paths
43
44
  idps: join(idpsDir, 'index.json'),
44
45
  idpSessionsDir,
45
46
  exchangeCredentials: join(base, 'exchange', 'credentials.json'),
47
+ sessionRoutes: join(base, 'session', 'routes.json'),
46
48
  idpDir: (name: string) => join(idpsDir, name),
47
49
  idpSession: (identityName: string) => join(idpSessionsDir, `${identityName}.json`),
48
50
  })
@@ -59,3 +61,4 @@ export const INSTANCES_PATH = paths.instances
59
61
  export const IDPS_PATH = paths.idps
60
62
  export const IDP_SESSIONS_DIR = paths.idpSessionsDir
61
63
  export const EXCHANGE_CREDENTIALS_PATH = paths.exchangeCredentials
64
+ export const SESSION_ROUTES_PATH = paths.sessionRoutes