@astrale-os/cli 0.4.0-alpha.13 → 0.5.0-alpha.0

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 (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +8687 -6398
  5. package/package.json +19 -21
  6. package/src/commands/__tests__/help-contract.test.ts +27 -14
  7. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  8. package/src/commands/__tests__/ls.test.ts +1 -1
  9. package/src/commands/__tests__/read-commands.test.ts +201 -0
  10. package/src/commands/call.ts +27 -44
  11. package/src/commands/describe.ts +57 -58
  12. package/src/commands/domain/install.ts +4 -4
  13. package/src/commands/get.ts +48 -23
  14. package/src/commands/identity/register.ts +27 -33
  15. package/src/commands/logs.ts +8 -8
  16. package/src/commands/ls.ts +77 -55
  17. package/src/commands/mutate.ts +191 -0
  18. package/src/commands/query.ts +288 -20
  19. package/src/commands/token.ts +4 -7
  20. package/src/kernel/__tests__/expand.test.ts +123 -0
  21. package/src/kernel/client.ts +3 -13
  22. package/src/kernel/expand.ts +52 -59
  23. package/src/kernel/graph.ts +96 -0
  24. package/src/kernel/index.ts +11 -2
  25. package/src/kernel/run.ts +0 -13
  26. package/src/lib/__tests__/table.test.ts +1 -1
  27. package/src/lib/admin-domain.ts +3 -3
  28. package/src/lib/domain-identity.ts +1 -1
  29. package/src/lib/self.ts +1 -3
  30. package/src/program.ts +5 -3
  31. package/src/setup/render.ts +1 -3
  32. package/studio/client/dist/assets/index-BcejyJpa.css +1 -0
  33. package/studio/client/dist/assets/index-Cqz3Oy_B.js +179 -0
  34. package/studio/client/dist/index.html +2 -2
  35. package/studio/server/agent/ask.ts +5 -1
  36. package/studio/server/agent/claude.ts +15 -3
  37. package/studio/server/api.ts +7 -7
  38. package/studio/server/introspect/overlay-tsmorph.ts +108 -50
  39. package/studio/server/state/harness-gateway.ts +12 -3
  40. package/studio/server/state/harness-token.ts +0 -0
  41. package/studio/server/state/visibility.ts +5 -1
  42. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  43. package/src/kernel/remote-routing.ts +0 -88
  44. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  45. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  46. package/studio/tsconfig.json +0 -23
@@ -1,32 +1,300 @@
1
- import { K } from '@astrale-os/kernel-core'
1
+ import { getInputSchema, K } from '@astrale-os/kernel-core'
2
+ import chalk from 'chalk'
2
3
 
3
4
  import type { CommandDefinition } from '../command'
4
- import type { KernelCommandOpts } from '../kernel'
5
+ import type { GetResultWire, KernelCommandOpts, QueryASTInput, SelfExpansionMeta } from '../kernel'
5
6
 
6
- import { runKernelCommand } from '../kernel'
7
+ import { bindGraph, expandSelfInPath, runKernelCommand, withSelfHint } from '../kernel'
8
+ import { log } from '../lib/log'
9
+ import { isMachine, output } from '../lib/output'
7
10
 
8
- export async function queryCommand(cypher: string, opts: KernelCommandOpts): Promise<void> {
9
- await runKernelCommand({
11
+ type QueryOpts = KernelCommandOpts & {
12
+ depth?: string
13
+ children?: string
14
+ edges?: string
15
+ ast?: string
16
+ cypher?: string
17
+ }
18
+
19
+ type QueryMode =
20
+ | { kind: 'cypher'; cypher: string }
21
+ | { kind: 'ast'; ast: QueryASTInput }
22
+ | { kind: 'roots'; roots: string[]; meta: SelfExpansionMeta | undefined; query: BuiltQuery }
23
+
24
+ export async function queryCommand(paths: string[], opts: QueryOpts): Promise<void> {
25
+ let mode: QueryMode
26
+ try {
27
+ mode = await parseMode(paths, opts)
28
+ } catch (e) {
29
+ log.error(e instanceof Error ? e.message : 'Invalid arguments')
30
+ process.exit(1)
31
+ return
32
+ }
33
+
34
+ if (mode.kind === 'cypher') {
35
+ await runKernelCommand({
36
+ opts,
37
+ label: 'Query',
38
+ fn: (ctx) => ctx.client.call(K.$.f('query').path.domain.raw, { cypher: mode.cypher }),
39
+ format: (result, fmtOpts) => output(result, fmtOpts),
40
+ })
41
+ return
42
+ }
43
+
44
+ await runKernelCommand<GetResultWire>({
10
45
  opts,
11
- label: 'Query',
12
- fn: (ctx) => ctx.client.call(K.Root.query.path.method.raw, { cypher }),
46
+ label: mode.kind === 'roots' ? 'Query ' + mode.roots.join(' ') : 'Query',
47
+ fn: async (ctx) => {
48
+ const read = () => bindGraph(ctx).query(mode.kind === 'roots' ? mode.query.ast : mode.ast)
49
+ const result =
50
+ mode.kind === 'roots' ? await withSelfHint(read, mode.meta) : await read()
51
+ return result.wire
52
+ },
53
+ format: (result, fmtOpts) => {
54
+ output(result, fmtOpts)
55
+ if (result.next && !isMachine(fmtOpts)) printCursorFooter(result.next)
56
+ },
57
+ })
58
+ }
59
+
60
+ async function parseMode(paths: string[], opts: QueryOpts): Promise<QueryMode> {
61
+ const rootsInput = paths ?? []
62
+ const hasRoots = rootsInput.length > 0
63
+ const hasAst = opts.ast !== undefined
64
+ const hasCypher = opts.cypher !== undefined
65
+ const hasSelectors = opts.depth !== undefined || opts.children !== undefined || opts.edges !== undefined
66
+
67
+ if (hasCypher) {
68
+ if (hasAst || hasRoots || hasSelectors) {
69
+ throw new Error('--cypher cannot be used with roots, --ast, --depth, --children, or --edges')
70
+ }
71
+ return { kind: 'cypher', cypher: opts.cypher as string }
72
+ }
73
+
74
+ if (hasAst) {
75
+ if (hasRoots || hasSelectors) {
76
+ throw new Error('--ast cannot be used with positional roots or --depth/--children/--edges')
77
+ }
78
+ return { kind: 'ast', ast: parseAst(opts.ast as string) }
79
+ }
80
+
81
+ if (!hasRoots) {
82
+ throw new Error(
83
+ 'Usage: astrale query <paths...> [--depth <n>] [--children <json>] [--edges <json>] | --ast <json> | --cypher <query>',
84
+ )
85
+ }
86
+
87
+ const { roots, meta } = await expandRoots(rootsInput, opts)
88
+ return { kind: 'roots', roots, meta, query: buildQuery(roots, opts) }
89
+ }
90
+
91
+ function parseAst(raw: string): QueryASTInput {
92
+ let parsed: unknown
93
+ try {
94
+ parsed = JSON.parse(raw)
95
+ } catch {
96
+ throw new Error('--ast must be JSON: ' + raw)
97
+ }
98
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
99
+ throw new Error('--ast must be a JSON object')
100
+ }
101
+ return parsed as QueryASTInput
102
+ }
103
+
104
+ async function expandRoots(
105
+ paths: string[],
106
+ opts: QueryOpts,
107
+ ): Promise<{ roots: string[]; meta: SelfExpansionMeta | undefined }> {
108
+ const roots: string[] = []
109
+ let meta: SelfExpansionMeta | undefined
110
+ for (const p of paths) {
111
+ const expanded = await expandSelfInPath(p, opts)
112
+ roots.push(expanded.path)
113
+ if (!meta && expanded.meta) meta = expanded.meta
114
+ }
115
+ return { roots, meta }
116
+ }
117
+
118
+ type QueryDir = 'in' | 'out' | 'both'
119
+ type QueryOrder = { by: string; dir: 'asc' | 'desc' }
120
+ type ChildrenSelector = { classes?: string[]; limit?: number; cursor?: string; order?: QueryOrder }
121
+ type EdgeSelector = {
122
+ as?: string
123
+ classes?: string[]
124
+ direction?: QueryDir
125
+ limit?: number
126
+ cursor?: string
127
+ order?: QueryOrder
128
+ }
129
+ type BuiltQuery = { ast: QueryASTInput; depth: number; hasEdges: boolean }
130
+
131
+ function buildQuery(roots: string[], opts: QueryOpts): BuiltQuery {
132
+ const depth = opts.depth !== undefined ? parseRange('--depth', opts.depth, 0, 5) : 0
133
+ const children =
134
+ opts.children !== undefined
135
+ ? parseSelector<ChildrenSelector>('--children', opts.children, getInputSchema.shape.children)
136
+ : undefined
137
+ const edges =
138
+ opts.edges !== undefined
139
+ ? parseSelector<EdgeSelector | EdgeSelector[]>(
140
+ '--edges',
141
+ opts.edges,
142
+ getInputSchema.shape.edges,
143
+ )
144
+ : undefined
145
+
146
+ const steps: NonNullable<QueryASTInput['steps']> = []
147
+ if (depth > 0) steps.push({ expand: childExpand(depth, children) })
148
+ edgeSelectors(edges).forEach((selector, index) => {
149
+ steps.push({ expand: edgeExpand(selector, index) })
13
150
  })
151
+
152
+ return {
153
+ ast: { version: 1, from: roots, ...(steps.length > 0 ? { steps } : {}) },
154
+ depth,
155
+ hasEdges: edges !== undefined,
156
+ }
157
+ }
158
+
159
+ function childExpand(depth: number, children: ChildrenSelector | undefined) {
160
+ return {
161
+ edge: 'has_parent',
162
+ dir: 'in' as const,
163
+ depth,
164
+ ...(children?.classes !== undefined ? { filter: { class: children.classes } } : {}),
165
+ ...pageFields(children),
166
+ ...(children?.order !== undefined ? { order: children.order } : {}),
167
+ }
168
+ }
169
+
170
+ function edgeExpand(selector: EdgeSelector, index: number) {
171
+ return {
172
+ ...(selector.classes !== undefined ? { edge: selector.classes } : {}),
173
+ dir: selector.direction ?? 'both',
174
+ as: selector.as ?? 'e' + index,
175
+ ...pageFields(selector),
176
+ ...(selector.order !== undefined ? { order: selector.order } : {}),
177
+ }
178
+ }
179
+
180
+ function pageFields(selector: { limit?: number; cursor?: string } | undefined) {
181
+ if (selector?.limit === undefined && selector?.cursor === undefined) return {}
182
+ return {
183
+ page: {
184
+ ...(selector.limit !== undefined ? { limit: selector.limit } : {}),
185
+ ...(selector.cursor !== undefined ? { cursor: selector.cursor } : {}),
186
+ },
187
+ }
188
+ }
189
+
190
+ function edgeSelectors(edges: EdgeSelector | EdgeSelector[] | undefined): EdgeSelector[] {
191
+ if (edges === undefined) return []
192
+ return Array.isArray(edges) ? edges : [edges]
193
+ }
194
+
195
+ function parseRange(flag: string, raw: string, min: number, max: number): number {
196
+ const n = Number(raw)
197
+ if (!Number.isInteger(n) || n < min || n > max) {
198
+ throw new Error(flag + ' needs an integer in [' + min + ', ' + max + '], got "' + raw + '"')
199
+ }
200
+ return n
201
+ }
202
+
203
+ type SelectorIssue = { readonly path: readonly PropertyKey[]; readonly message: string }
204
+ type SelectorSchema = {
205
+ safeParse(
206
+ value: unknown,
207
+ ): { success: true } | { success: false; error: { issues: readonly SelectorIssue[] } }
208
+ }
209
+
210
+ function parseSelector<T>(flag: string, raw: string, schema: SelectorSchema): T {
211
+ let parsed: unknown
212
+ try {
213
+ parsed = JSON.parse(raw)
214
+ } catch {
215
+ throw new Error(flag + ' must be JSON: ' + raw)
216
+ }
217
+ if (parsed === null || typeof parsed !== 'object') {
218
+ throw new Error(flag + ' must be a JSON selector object' + (flag === '--edges' ? ' or array' : ''))
219
+ }
220
+ const check = schema.safeParse(parsed)
221
+ if (!check.success) {
222
+ const detail = check.error.issues
223
+ .map((i) => (i.path.join('.') || '(root)') + ': ' + i.message)
224
+ .join('; ')
225
+ throw new Error(flag + ' invalid selector: ' + detail)
226
+ }
227
+ return parsed as T
228
+ }
229
+
230
+ function printCursorFooter(next: NonNullable<GetResultWire['next']>): void {
231
+ const entries = Object.entries(next)
232
+ if (entries.length === 1) {
233
+ const cursors = entries[0]?.[1]
234
+ if (cursors?.children) {
235
+ process.stdout.write(chalk.dim(' more: --children \'{"cursor":"' + cursors.children + '"}\'\n'))
236
+ }
237
+ for (const [alias, cursor] of Object.entries(cursors?.edges ?? {})) {
238
+ process.stdout.write(
239
+ chalk.dim(' more edges[' + alias + ']: --edges \'{"as":"' + alias + '","cursor":"' + cursor + '"}\'\n'),
240
+ )
241
+ }
242
+ return
243
+ }
244
+ process.stdout.write(
245
+ chalk.dim(' more results - per-root cursors in .next (page each root by --cursor)\n'),
246
+ )
14
247
  }
15
248
 
16
249
  export default {
17
250
  name: 'query',
18
- description: 'Run a read-only Cypher query against the kernel graph',
19
- afterHelpText: `
20
- Behavior:
21
- Read-only. The kernel rejects write keywords (CREATE, DELETE, SET,
22
- MERGE, REMOVE, DETACH); enforcement is kernel-side.
23
-
24
- Examples:
25
- $ astrale query 'MATCH (n) RETURN count(n) AS total'
26
- $ astrale query 'MATCH (n:Domain) RETURN n.slug, n.id'
27
- `,
28
- arguments: [{ name: 'cypher', description: 'Cypher query string' }],
29
- action: async (cypher, opts) => {
30
- await queryCommand(cypher as string, opts as KernelCommandOpts)
251
+ description: 'Run a structured graph read',
252
+ afterHelpText: [
253
+ '',
254
+ 'Behavior:',
255
+ ' Structured read door for the query AST. Positional roots build a v1 AST',
256
+ ' with optional child and edge expansion, then lower through function.get',
257
+ ' today. A true query syscall may back this command later.',
258
+ '',
259
+ ' Output is always the full GraphData envelope { nodes, edges, aliases }',
260
+ ' with .roots and .next when returned. On a TTY, cursor footers are printed',
261
+ ' when .next has more pages.',
262
+ '',
263
+ ' --children takes { classes?, limit?, cursor?, order? } and shapes the',
264
+ ' depth-1 children page (needs --depth >= 1 to bite). --edges takes an edge',
265
+ ' selector, or a JSON array of selectors.',
266
+ '',
267
+ ' --ast (experimental) accepts a raw QueryASTInput JSON object. The AST shape',
268
+ ' is not a stable contract yet — prefer the flags above. @self is not expanded',
269
+ ' inside --ast JSON.',
270
+ '',
271
+ ' --cypher is a read-only escape hatch. The kernel rejects write keywords',
272
+ ' such as CREATE, DELETE, SET, MERGE, REMOVE, and DETACH.',
273
+ '',
274
+ 'Examples:',
275
+ ' $ astrale query / --depth 1',
276
+ ' $ astrale query /a /b --edges \'{"direction":"both"}\'',
277
+ ' $ astrale query /kernel.astrale.ai --depth 2 --children \'{"classes":["/:kernel.astrale.ai:class.Folder"]}\'',
278
+ " $ astrale query --cypher 'MATCH (n) RETURN count(n) AS total'",
279
+ '',
280
+ ].join('\n'),
281
+ arguments: [
282
+ { name: 'paths...', description: 'One or more root paths (/domain/Class) or IDs (@nodeId)', required: false },
283
+ ],
284
+ options: [
285
+ { flags: '--depth <n>', description: 'Subtree depth to fetch (0-5, default 0)' },
286
+ {
287
+ flags: '--children <json>',
288
+ description: 'Children selector { classes?, limit?, cursor?, order? } (needs --depth >= 1)',
289
+ },
290
+ {
291
+ flags: '--edges <json>',
292
+ description: 'Edge selector (or JSON array of selectors) to include',
293
+ },
294
+ { flags: '--ast <json>', description: 'Raw QueryASTInput JSON object (experimental, unstable shape)' },
295
+ { flags: '--cypher <query>', description: 'Read-only Cypher escape hatch' },
296
+ ],
297
+ action: async (paths, opts) => {
298
+ await queryCommand(Array.isArray(paths) ? paths : [], opts as QueryOpts)
31
299
  },
32
300
  } satisfies CommandDefinition
@@ -2,12 +2,11 @@ import type { CommandDefinition } from '../command'
2
2
  import type { KernelCommandOpts } from '../kernel'
3
3
 
4
4
  import { runKernelCommand } from '../kernel'
5
- import { mintDelegationPath } from '../kernel/remote-routing'
6
5
  import { log } from '../lib/log'
7
6
 
8
7
  /**
9
8
  * `astrale token` — mint a fresh delegation token for the active instance
10
- * + active identity (§2.5). Shortcut over the mintDelegationCredential call.
9
+ * + active identity through the bound AuthApi.
11
10
  */
12
11
  export type TokenOpts = KernelCommandOpts & {
13
12
  audience?: string
@@ -26,12 +25,10 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
26
25
  const audience = opts.audience ?? ''
27
26
  const parsedTtl = Number(opts.ttl)
28
27
  const ttl = Number.isFinite(parsedTtl) && parsedTtl > 0 ? parsedTtl : 3600
29
- const mintPath = await mintDelegationPath(ctx.client, ctx.credential)
30
- const result = (await ctx.client.call(mintPath, {
28
+ const result = await ctx.client.as(ctx.credential).auth.delegate({
31
29
  audience,
32
- delegation: { kind: 'identity', self: true },
33
30
  ttl,
34
- })) as string
31
+ })
35
32
  return result
36
33
  },
37
34
  format: (token, fmtOpts, isRaw) => {
@@ -63,7 +60,7 @@ What this token is FOR — worker-direct HTTP calls:
63
60
  use 'astrale call' (which signs per-call) instead.
64
61
 
65
62
  Examples:
66
- $ export TOKEN=$(astrale token --audience dist.astrale.ai --raw)
63
+ $ export TOKEN=$(astrale token --audience workspace.astrale.ai --raw)
67
64
  $ astrale token --audience worker.example.com --for alice -i staging
68
65
  `,
69
66
  options: [
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { SelfResolverContext } from '../../lib/self'
4
+
5
+ import { resolveSelfIdLazy } from '../expand'
6
+
7
+ describe('resolveSelfIdLazy', () => {
8
+ test('refreshes a cached IdP @self registration via whoami', async () => {
9
+ const writes: unknown[][] = []
10
+ const ctx: SelfResolverContext = {
11
+ identity: {
12
+ name: 'bryan',
13
+ subject: 'user_01KC9MW1M6S5J6V9ERSRJ8RDYF',
14
+ createdAt: '2026-06-25T08:00:00.000Z',
15
+ source: 'idp',
16
+ registrations: {
17
+ bryan: {
18
+ iss: 'https://old.example',
19
+ sub: '4ad8e4ce-5cf7-4c2e-ab29-5549023dc8bf',
20
+ registeredAt: '2026-06-25T08:00:00.000Z',
21
+ },
22
+ },
23
+ },
24
+ instanceSlug: 'bryan',
25
+ instanceSigned: false,
26
+ }
27
+
28
+ const id = await resolveSelfIdLazy(
29
+ ctx,
30
+ {},
31
+ {
32
+ whoami: async () => ({
33
+ id: 'f011538e-9edc-4c29-92ce-9b81b6c1b6c7',
34
+ kernelUrl: 'https://bryan.example',
35
+ }),
36
+ setRegistration: async (...args) => {
37
+ writes.push(args)
38
+ },
39
+ now: () => new Date('2026-06-25T08:15:00.000Z'),
40
+ },
41
+ )
42
+
43
+ expect(id).toBe('f011538e-9edc-4c29-92ce-9b81b6c1b6c7')
44
+ expect(writes).toEqual([
45
+ [
46
+ 'bryan',
47
+ 'bryan',
48
+ {
49
+ iss: 'https://bryan.example',
50
+ sub: 'f011538e-9edc-4c29-92ce-9b81b6c1b6c7',
51
+ registeredAt: '2026-06-25T08:15:00.000Z',
52
+ },
53
+ ],
54
+ ])
55
+ })
56
+
57
+ test('keeps the cached IdP id when whoami is temporarily unavailable', async () => {
58
+ const ctx: SelfResolverContext = {
59
+ identity: {
60
+ name: 'bryan',
61
+ subject: 'user_01KC9MW1M6S5J6V9ERSRJ8RDYF',
62
+ createdAt: '2026-06-25T08:00:00.000Z',
63
+ source: 'idp',
64
+ registrations: {
65
+ bryan: {
66
+ iss: 'https://bryan.example',
67
+ sub: 'cached-id',
68
+ registeredAt: '2026-06-25T08:00:00.000Z',
69
+ },
70
+ },
71
+ },
72
+ instanceSlug: 'bryan',
73
+ instanceSigned: false,
74
+ }
75
+
76
+ await expect(
77
+ resolveSelfIdLazy(
78
+ ctx,
79
+ {},
80
+ {
81
+ whoami: async () => {
82
+ throw new Error('network down')
83
+ },
84
+ },
85
+ ),
86
+ ).resolves.toBe('cached-id')
87
+ })
88
+
89
+ test('does not run whoami for key-backed registrations', async () => {
90
+ const ctx: SelfResolverContext = {
91
+ identity: {
92
+ name: 'alice',
93
+ subject: 'alice',
94
+ createdAt: '2026-06-25T08:00:00.000Z',
95
+ source: 'key',
96
+ registrations: {
97
+ bryan: {
98
+ iss: 'https://bryan.example',
99
+ sub: 'key-node-id',
100
+ registeredAt: '2026-06-25T08:00:00.000Z',
101
+ },
102
+ },
103
+ },
104
+ instanceSlug: 'bryan',
105
+ instanceSigned: false,
106
+ }
107
+ let called = false
108
+
109
+ const id = await resolveSelfIdLazy(
110
+ ctx,
111
+ {},
112
+ {
113
+ whoami: async () => {
114
+ called = true
115
+ return { id: 'should-not-be-used', kernelUrl: 'https://bryan.example' }
116
+ },
117
+ },
118
+ )
119
+
120
+ expect(id).toBe('key-node-id')
121
+ expect(called).toBe(false)
122
+ })
123
+ })
@@ -1,5 +1,5 @@
1
1
  import { KernelClient, type FnMap } from '@astrale-os/kernel-client'
2
- import { ClientSession } from '@astrale-os/kernel-client/session'
2
+ import { ClientSession, delegationMintVia } from '@astrale-os/kernel-client/session'
3
3
 
4
4
  import type { AdminTargetCommandOpts } from '../lib/admin-target'
5
5
  import type { KernelCommandOpts } from './types'
@@ -10,7 +10,6 @@ import { readConfig } from '../lib/config'
10
10
  import { resolveInstanceTarget, type ResolvedInstanceTarget } from '../lib/instance-target'
11
11
  import { resolveCredential } from './auth'
12
12
  import { fetchWithCaFile } from './ca-fetch'
13
- import { mintRemoteCredential } from './remote-routing'
14
13
 
15
14
  const DEFAULT_TIMEOUT_MS = 30_000
16
15
 
@@ -131,21 +130,12 @@ async function withResolvedKernelClient<T>(
131
130
  // exponential backoff on ECONNREFUSED / 5xx). The user can re-run.
132
131
  const requestTimeout = resolveTimeoutMs(opts.timeout)
133
132
  const fetchImpl = target.caFile ? fetchWithCaFile(target.caFile) : undefined
134
- // The delegation mint references `client` lazily — it only fires on a cache
135
- // miss during an actual remote call, long after this binding is initialised,
136
- // so the self-reference inside the closure is safe.
137
133
  const client: ClientSession<FnMap> = new ClientSession<FnMap>({
138
134
  default: target.url,
139
135
  identity: credential,
140
- // Remote-bound functions redirect to a worker that verifies `aud` against
141
- // its own identity. The session follows the redirect and mints a worker-
142
- // scoped delegation here, for the audience the kernel carries on the
143
- // redirect (`redirection.iss`, surfaced by the default iss-aware policy).
144
136
  delegation: {
145
- mint: async (audience) => ({
146
- credential: await mintRemoteCredential(client, audience, credential),
147
- ttl: 3600,
148
- }),
137
+ // Resolve lazily: the session must exist before the cache mints.
138
+ mint: delegationMintVia(() => client, credential),
149
139
  ttl: 3600,
150
140
  },
151
141
  pool: {
@@ -2,13 +2,7 @@ import type { InstanceInfo } from '../lib/admin-instance'
2
2
  import type { KernelCommandOpts } from './types'
3
3
 
4
4
  import { ADMIN_INSTANCE } from '../lib/admin-instance'
5
- /**
6
- * Bridges `lib/self.ts` to CLI command sites: builds a `SelfResolverContext`
7
- * from CLI opts, resolves a nodeId via
8
- * `resolveOrThrow` (throwing `SelfRefusalError` on refusal), and wraps async
9
- * calls with `withSelfHint` so `NotFoundError`s carry expansion metadata for
10
- * the stale-registration hint emitted by `formatKernelError`.
11
- */
5
+ /** CLI bridge for @self resolution and stale-registration error hints. */
12
6
  import { readConfig } from '../lib/config'
13
7
  import { getDefault, getIdentity, setRegistration } from '../lib/identity'
14
8
  import { decodeTokenClaims, readIdpSession } from '../lib/idp'
@@ -34,13 +28,7 @@ export type SelfExpansionMeta = {
34
28
  slug?: string
35
29
  }
36
30
 
37
- /**
38
- * Build a `SelfResolverContext` from CLI opts. Mirrors the target +
39
- * signing-mode logic in `withKernelClient` / `resolveCredential`.
40
- *
41
- * Cheap enough to call eagerly; commands skip the call entirely when
42
- * `containsSelfRef` returns false on every input.
43
- */
31
+ /** Build the same target/signing context `withKernelClient` will use. */
44
32
  export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfResolverContext> {
45
33
  const config = await readConfig()
46
34
  // Mirror withKernelClient's slug logic: --url without -i ⇒ no slug.
@@ -70,11 +58,7 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
70
58
  instanceSigned = await fileExists(privatePath)
71
59
  }
72
60
 
73
- // Identity that will sign (only relevant when not instance-signed and not --creds).
74
- // Failures here (corrupt identities.json, missing --as identity) are
75
- // re-thrown rather than swallowed — swallowing produces a useless
76
- // refusal naming `identityName: '(unknown)'`. The fatal-error UX is
77
- // honest about the actual problem.
61
+ // Identity lookup failures should surface as real CLI errors, not @self refusals.
78
62
  let identity: SelfResolverContext['identity']
79
63
  if (!opts.creds) {
80
64
  const identityName = opts.as ?? defaultIdentity
@@ -142,59 +126,68 @@ export function resolveOrThrow(selfCtx: SelfResolverContext): string {
142
126
  return r.id
143
127
  }
144
128
 
145
- // The kernel's whoami — returns the AUTHENTICATED principal's graph node.
146
- // Static interface method, so the colon form (slash form is rejected).
147
- const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
129
+ export type ResolveSelfIdLazyDeps = {
130
+ whoami?: (opts: KernelCommandOpts) => Promise<{ id?: unknown; kernelUrl: string }>
131
+ setRegistration?: typeof setRegistration
132
+ now?: () => Date
133
+ }
148
134
 
149
135
  /**
150
- * Resolve `@self`, falling back to ONE kernel `whoami` round-trip when an
151
- * IdP identity merely lacks a cached registration on this instance (the
152
- * normal `astrale auth login` flow — the IdP subject is never a node id).
153
- * The resolved id is persisted as a registration so subsequent expansions
154
- * are local again. Every other refusal (manager, instance-signed, …) and a
155
- * failed whoami throw the typed refusal unchanged.
136
+ * Resolve @self for IdP identities through one whoami refresh, then cache the
137
+ * current node id. Non-IdP refusals stay typed and local.
156
138
  */
157
139
  export async function resolveSelfIdLazy(
158
140
  selfCtx: SelfResolverContext,
159
141
  opts: KernelCommandOpts,
142
+ deps: ResolveSelfIdLazyDeps = {},
160
143
  ): Promise<string> {
161
144
  const r: SelfResolution = resolveSelfNodeId(selfCtx)
162
- if (!('reason' in r)) return r.id
163
- if (r.reason !== 'idp-no-sub' || !selfCtx.instanceSlug || !selfCtx.identity) {
164
- throw selfRefusalError(r)
145
+ const cachedId = 'reason' in r ? undefined : r.id
146
+ const isIdp = (selfCtx.identity?.source ?? 'key') === 'idp'
147
+
148
+ if (isIdp && selfCtx.instanceSlug && selfCtx.identity) {
149
+ let lookup: { id?: unknown; kernelUrl: string }
150
+ try {
151
+ lookup = await (deps.whoami ?? whoamiSelfId)(opts)
152
+ } catch {
153
+ if (cachedId) return cachedId
154
+ if ('reason' in r) throw selfRefusalError(r)
155
+ throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
156
+ }
157
+
158
+ const resolvedId =
159
+ typeof lookup.id === 'string' && lookup.id.trim().length > 0 ? lookup.id : undefined
160
+ if (!resolvedId) {
161
+ if (cachedId) return cachedId
162
+ if ('reason' in r) throw selfRefusalError(r)
163
+ throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
164
+ }
165
+
166
+ const cached = selfCtx.identity.registrations?.[selfCtx.instanceSlug]
167
+ if (cached?.sub !== resolvedId || cached?.iss !== lookup.kernelUrl) {
168
+ await (deps.setRegistration ?? setRegistration)(selfCtx.identity.name, selfCtx.instanceSlug, {
169
+ iss: lookup.kernelUrl,
170
+ sub: resolvedId,
171
+ registeredAt: (deps.now ?? (() => new Date()))().toISOString(),
172
+ })
173
+ }
174
+ return resolvedId
165
175
  }
166
- let me: { id?: unknown } | null
176
+
177
+ if (!('reason' in r)) return r.id
178
+ throw selfRefusalError(r)
179
+ }
180
+
181
+ async function whoamiSelfId(opts: KernelCommandOpts): Promise<{ id?: unknown; kernelUrl: string }> {
167
182
  let kernelUrl = ''
168
- try {
169
- me = (await withKernelClient(opts, (ctx) => {
170
- kernelUrl = ctx.url
171
- return ctx.client.call(WHOAMI_PATH as never, {} as never)
172
- })) as { id?: unknown } | null
173
- } catch {
174
- // Network/auth failure — surface the original recipe, not a stack.
175
- throw selfRefusalError(r)
176
- }
177
- const id = typeof me?.id === 'string' && me.id.trim().length > 0 ? me.id : undefined
178
- if (!id) throw selfRefusalError(r)
179
- await setRegistration(selfCtx.identity.name, selfCtx.instanceSlug, {
180
- iss: kernelUrl,
181
- sub: id,
182
- registeredAt: new Date().toISOString(),
183
+ const me = await withKernelClient(opts, (ctx) => {
184
+ kernelUrl = ctx.url
185
+ return ctx.client.as(ctx.credential).auth.whoami()
183
186
  })
184
- return id
187
+ return { id: me.id, kernelUrl }
185
188
  }
186
189
 
187
- /**
188
- * Expand `@self` in a single path string for the common command shape
189
- * (`get`, `ls`, `describe`). Returns the expanded path AND the metadata
190
- * needed by `withSelfHint` to attach the stale-registration hint to a
191
- * downstream `NotFoundError`.
192
- *
193
- * No-op (returns the input unchanged with `meta: undefined`) when the path
194
- * contains no `@self` — avoids the I/O of `buildSelfContext`.
195
- *
196
- * Throws `SelfRefusalError` when `@self` is present but unresolvable.
197
- */
190
+ /** Expand @self in path-like commands and return metadata for NotFound hints. */
198
191
  export async function expandSelfInPath(
199
192
  path: string,
200
193
  opts: KernelCommandOpts,