@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.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +8687 -6398
- package/package.json +19 -21
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +4 -4
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +288 -20
- package/src/commands/token.ts +4 -7
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +3 -13
- package/src/kernel/expand.ts +52 -59
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +11 -2
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/admin-domain.ts +3 -3
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/self.ts +1 -3
- package/src/program.ts +5 -3
- package/src/setup/render.ts +1 -3
- package/studio/client/dist/assets/index-BcejyJpa.css +1 -0
- package/studio/client/dist/assets/index-Cqz3Oy_B.js +179 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +5 -1
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/api.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.ts +108 -50
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/visibility.ts +5 -1
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- package/studio/tsconfig.json +0 -23
package/src/commands/query.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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) =>
|
|
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
|
|
19
|
-
afterHelpText:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
package/src/commands/token.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
+
})
|
package/src/kernel/client.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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: {
|
package/src/kernel/expand.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
*
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|