@astrale-os/cli 0.4.0-alpha.13 → 0.6.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 +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- 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/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- 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 +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- 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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect-core — the frozen, curated re-export surface the connect-host adapter
|
|
3
|
+
* borrows from the CLI. It is the ONE public seam between `@astrale-os/cli` and
|
|
4
|
+
* `@astrale-os/connect-host`; nothing else in cli/src is contract.
|
|
5
|
+
*
|
|
6
|
+
* Consumed by `@astrale-os/connect-host`; temps-2 seed of a future
|
|
7
|
+
* `@astrale-os/client-core`. Re-export ONLY — no logic lives here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── identity registry (~/.astrale/identities.json) ──────────────────────────
|
|
11
|
+
export { readIdentities, getDefault, getIdentity } from './lib/identity'
|
|
12
|
+
export type { Identity, IdentityStore } from './lib/identity'
|
|
13
|
+
|
|
14
|
+
// ── keypairs on disk + self-grant mint ──────────────────────────────────────
|
|
15
|
+
export { signAs, listIdentityKeys } from './lib/keys'
|
|
16
|
+
|
|
17
|
+
// ── instance bookmarks (~/.astrale/instances.json) ──────────────────────────
|
|
18
|
+
export {
|
|
19
|
+
readInstances,
|
|
20
|
+
resetInstancesMemo,
|
|
21
|
+
resolveInstance,
|
|
22
|
+
normalizeInstanceKernelUrl,
|
|
23
|
+
orgIdForAudience,
|
|
24
|
+
} from './lib/instance'
|
|
25
|
+
export type { InstanceEntry, InstanceStore, ResolvedInstance } from './lib/instance'
|
|
26
|
+
|
|
27
|
+
// ── instance-target resolution (bookmark | managed | admin | free url) ──────
|
|
28
|
+
export { resolveInstanceTarget } from './lib/instance-target'
|
|
29
|
+
export type { ResolvedInstanceTarget } from './lib/instance-target'
|
|
30
|
+
|
|
31
|
+
// ── global config (~/.astrale/config.json) ──────────────────────────────────
|
|
32
|
+
export { readConfig } from './lib/config'
|
|
33
|
+
export type { AstraleConfig } from './lib/config'
|
|
34
|
+
|
|
35
|
+
// ── credential resolution (self-grant + IdP) ────────────────────────────────
|
|
36
|
+
export { resolveCredential, IdpRefreshTransientError, IdpOrgMembershipError } from './kernel/auth'
|
|
37
|
+
|
|
38
|
+
// ── IdP login (device flow) — driven by connect-host's `auth.login` op ───────
|
|
39
|
+
export { loginViaIdp, resolveIdpName } from './lib/login-flow'
|
|
40
|
+
export type { LoginFlowOpts, LoginResult, DeviceVerification } from './lib/login-flow'
|
|
41
|
+
|
|
42
|
+
// ── IdP session state (so connect-host can flag "login required" rows) ───────
|
|
43
|
+
export { readIdpSession, isSessionExpired } from './lib/idp'
|
|
44
|
+
export type { IdpSession } from './lib/idp'
|
|
45
|
+
|
|
46
|
+
// ── typed errors ────────────────────────────────────────────────────────────
|
|
47
|
+
export { AuthError } from './errors'
|
|
48
|
+
|
|
49
|
+
// ── custom-CA fetch (private-CA local stacks) ───────────────────────────────
|
|
50
|
+
export { fetchWithCaFile } from './kernel/ca-fetch'
|
|
51
|
+
|
|
52
|
+
// ── path resolution (~/.astrale layout) ─────────────────────────────────────
|
|
53
|
+
export { paths, createPaths } from './lib/env'
|
|
@@ -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,16 +1,15 @@
|
|
|
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'
|
|
6
6
|
|
|
7
7
|
import { AstraleError } from '../errors'
|
|
8
|
-
import {
|
|
8
|
+
import { adminInstanceMethod, type InstanceInfo } from '../lib/admin-instance'
|
|
9
9
|
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
|
|
|
@@ -22,7 +21,7 @@ export type ClientContext = {
|
|
|
22
21
|
config: Awaited<ReturnType<typeof readConfig>>
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
type ResolvedKernelTarget = {
|
|
24
|
+
export type ResolvedKernelTarget = {
|
|
26
25
|
url: string
|
|
27
26
|
audience: string
|
|
28
27
|
slug?: string
|
|
@@ -30,44 +29,50 @@ type ResolvedKernelTarget = {
|
|
|
30
29
|
caFile?: string
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
* The new client is lazy: construction does no I/O. We only need to
|
|
36
|
-
* release sockets on the way out.
|
|
37
|
-
*/
|
|
38
|
-
export async function withKernelClient<T>(
|
|
32
|
+
/** Resolve the kernel a command targets (`--url` / `-i` / active instance). */
|
|
33
|
+
export async function resolveKernelTarget(
|
|
39
34
|
opts: KernelCommandOpts,
|
|
40
|
-
|
|
41
|
-
): Promise<
|
|
42
|
-
const config = await readConfig()
|
|
35
|
+
config: Awaited<ReturnType<typeof readConfig>>,
|
|
36
|
+
): Promise<ResolvedKernelTarget> {
|
|
43
37
|
// Ad-hoc `--url` — unknown kernel. Stamp the URL itself as audience,
|
|
44
38
|
// no slug for per-instance signing.
|
|
45
|
-
let target: ResolvedKernelTarget
|
|
46
39
|
if (opts.url && !opts.instance) {
|
|
47
40
|
const resolved = await resolveInstanceTarget(
|
|
48
41
|
{ source: 'url', url: opts.url },
|
|
49
42
|
{ config, admin: adminLookupOpts(opts) },
|
|
50
43
|
)
|
|
51
|
-
|
|
52
|
-
} else {
|
|
53
|
-
const resolved = await resolveInstanceTarget(
|
|
54
|
-
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
55
|
-
{
|
|
56
|
-
config,
|
|
57
|
-
admin: adminLookupOpts(opts),
|
|
58
|
-
managed: (slug) => lookupManagedInstance(slug, opts),
|
|
59
|
-
},
|
|
60
|
-
)
|
|
61
|
-
target = resolvedToKernelTarget(resolved, opts.url)
|
|
44
|
+
return resolvedToKernelTarget(resolved)
|
|
62
45
|
}
|
|
46
|
+
const resolved = await resolveInstanceTarget(
|
|
47
|
+
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
48
|
+
{
|
|
49
|
+
config,
|
|
50
|
+
admin: adminLookupOpts(opts),
|
|
51
|
+
managed: (slug) => lookupManagedInstance(slug, opts),
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
return resolvedToKernelTarget(resolved, opts.url)
|
|
55
|
+
}
|
|
63
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Connect to a kernel instance, run `fn`, then disconnect.
|
|
59
|
+
* The new client is lazy: construction does no I/O. We only need to
|
|
60
|
+
* release sockets on the way out.
|
|
61
|
+
*/
|
|
62
|
+
export async function withKernelClient<T>(
|
|
63
|
+
opts: KernelCommandOpts,
|
|
64
|
+
fn: (ctx: ClientContext) => Promise<T>,
|
|
65
|
+
): Promise<T> {
|
|
66
|
+
const config = await readConfig()
|
|
67
|
+
const target = await resolveKernelTarget(opts, config)
|
|
64
68
|
return withResolvedKernelClient(opts, config, target, fn)
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
68
72
|
return await withAdminKernelClient(
|
|
69
73
|
adminLookupOpts(opts),
|
|
70
|
-
async (ctx) =>
|
|
74
|
+
async (ctx) =>
|
|
75
|
+
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
71
76
|
)
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -131,21 +136,12 @@ async function withResolvedKernelClient<T>(
|
|
|
131
136
|
// exponential backoff on ECONNREFUSED / 5xx). The user can re-run.
|
|
132
137
|
const requestTimeout = resolveTimeoutMs(opts.timeout)
|
|
133
138
|
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
139
|
const client: ClientSession<FnMap> = new ClientSession<FnMap>({
|
|
138
140
|
default: target.url,
|
|
139
141
|
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
142
|
delegation: {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
ttl: 3600,
|
|
148
|
-
}),
|
|
143
|
+
// Resolve lazily: the session must exist before the cache mints.
|
|
144
|
+
mint: delegationMintVia(() => client, credential),
|
|
149
145
|
ttl: 3600,
|
|
150
146
|
},
|
|
151
147
|
pool: {
|
package/src/kernel/expand.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import type { InstanceInfo } from '../lib/admin-instance'
|
|
2
2
|
import type { KernelCommandOpts } from './types'
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
-
*/
|
|
4
|
+
import { adminInstanceMethod } from '../lib/admin-instance'
|
|
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
|
|
@@ -111,7 +95,8 @@ function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
|
|
|
111
95
|
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
112
96
|
return await withAdminKernelClient(
|
|
113
97
|
adminLookupOpts(opts),
|
|
114
|
-
async (ctx) =>
|
|
98
|
+
async (ctx) =>
|
|
99
|
+
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
115
100
|
)
|
|
116
101
|
}
|
|
117
102
|
|
|
@@ -142,59 +127,68 @@ export function resolveOrThrow(selfCtx: SelfResolverContext): string {
|
|
|
142
127
|
return r.id
|
|
143
128
|
}
|
|
144
129
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
130
|
+
export type ResolveSelfIdLazyDeps = {
|
|
131
|
+
whoami?: (opts: KernelCommandOpts) => Promise<{ id?: unknown; kernelUrl: string }>
|
|
132
|
+
setRegistration?: typeof setRegistration
|
|
133
|
+
now?: () => Date
|
|
134
|
+
}
|
|
148
135
|
|
|
149
136
|
/**
|
|
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.
|
|
137
|
+
* Resolve @self for IdP identities through one whoami refresh, then cache the
|
|
138
|
+
* current node id. Non-IdP refusals stay typed and local.
|
|
156
139
|
*/
|
|
157
140
|
export async function resolveSelfIdLazy(
|
|
158
141
|
selfCtx: SelfResolverContext,
|
|
159
142
|
opts: KernelCommandOpts,
|
|
143
|
+
deps: ResolveSelfIdLazyDeps = {},
|
|
160
144
|
): Promise<string> {
|
|
161
145
|
const r: SelfResolution = resolveSelfNodeId(selfCtx)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
146
|
+
const cachedId = 'reason' in r ? undefined : r.id
|
|
147
|
+
const isIdp = (selfCtx.identity?.source ?? 'key') === 'idp'
|
|
148
|
+
|
|
149
|
+
if (isIdp && selfCtx.instanceSlug && selfCtx.identity) {
|
|
150
|
+
let lookup: { id?: unknown; kernelUrl: string }
|
|
151
|
+
try {
|
|
152
|
+
lookup = await (deps.whoami ?? whoamiSelfId)(opts)
|
|
153
|
+
} catch {
|
|
154
|
+
if (cachedId) return cachedId
|
|
155
|
+
if ('reason' in r) throw selfRefusalError(r)
|
|
156
|
+
throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const resolvedId =
|
|
160
|
+
typeof lookup.id === 'string' && lookup.id.trim().length > 0 ? lookup.id : undefined
|
|
161
|
+
if (!resolvedId) {
|
|
162
|
+
if (cachedId) return cachedId
|
|
163
|
+
if ('reason' in r) throw selfRefusalError(r)
|
|
164
|
+
throw selfRefusalError({ reason: 'idp-no-sub', identityName: selfCtx.identity.name })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cached = selfCtx.identity.registrations?.[selfCtx.instanceSlug]
|
|
168
|
+
if (cached?.sub !== resolvedId || cached?.iss !== lookup.kernelUrl) {
|
|
169
|
+
await (deps.setRegistration ?? setRegistration)(selfCtx.identity.name, selfCtx.instanceSlug, {
|
|
170
|
+
iss: lookup.kernelUrl,
|
|
171
|
+
sub: resolvedId,
|
|
172
|
+
registeredAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return resolvedId
|
|
165
176
|
}
|
|
166
|
-
|
|
177
|
+
|
|
178
|
+
if (!('reason' in r)) return r.id
|
|
179
|
+
throw selfRefusalError(r)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function whoamiSelfId(opts: KernelCommandOpts): Promise<{ id?: unknown; kernelUrl: string }> {
|
|
167
183
|
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(),
|
|
184
|
+
const me = await withKernelClient(opts, (ctx) => {
|
|
185
|
+
kernelUrl = ctx.url
|
|
186
|
+
return ctx.client.as(ctx.credential).auth.whoami()
|
|
183
187
|
})
|
|
184
|
-
return id
|
|
188
|
+
return { id: me.id, kernelUrl }
|
|
185
189
|
}
|
|
186
190
|
|
|
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
|
-
*/
|
|
191
|
+
/** Expand @self in path-like commands and return metadata for NotFound hints. */
|
|
198
192
|
export async function expandSelfInPath(
|
|
199
193
|
path: string,
|
|
200
194
|
opts: KernelCommandOpts,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GetResultWire,
|
|
3
|
+
GraphApi,
|
|
4
|
+
GraphNodeWire,
|
|
5
|
+
MutationResultWire,
|
|
6
|
+
PatchInput,
|
|
7
|
+
QueryASTInput,
|
|
8
|
+
} from '@astrale-os/kernel-client/graph'
|
|
9
|
+
|
|
10
|
+
import { createGraph, rawOf } from '@astrale-os/kernel-client/graph'
|
|
11
|
+
|
|
12
|
+
import type { ClientContext } from './client'
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
GetResultWire,
|
|
16
|
+
GraphApi,
|
|
17
|
+
GraphNodeWire,
|
|
18
|
+
MutationResultWire,
|
|
19
|
+
PatchInput,
|
|
20
|
+
QueryASTInput,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A node row as it crosses the graph doors — the shared item shape for `get`/`ls`/`describe`. */
|
|
24
|
+
export type GraphNode = GraphNodeWire
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Bind the graph sugar (`function.get` / `function.mutate` + the read/write
|
|
28
|
+
* helpers) over a CLI client session. The sugar is pure over a call capability;
|
|
29
|
+
* we hand it `ClientSession.call` — the same surface every command already uses.
|
|
30
|
+
*/
|
|
31
|
+
export function bindGraph(ctx: ClientContext): GraphApi {
|
|
32
|
+
return createGraph((method, params) => ctx.client.call(method, params))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Split a `function.get` page into addressed root and descendants. Root matches
|
|
37
|
+
* path/id first, then falls back to the shallowest node in the page.
|
|
38
|
+
*/
|
|
39
|
+
export function splitRoot(
|
|
40
|
+
nodes: readonly GraphNode[],
|
|
41
|
+
rootPath: string,
|
|
42
|
+
): { root: GraphNode | undefined; children: GraphNode[] } {
|
|
43
|
+
const raw = rawOf(rootPath)
|
|
44
|
+
const idForm = rootPath.startsWith('@') ? rootPath.slice(1) : undefined
|
|
45
|
+
|
|
46
|
+
let idx = nodes.findIndex(
|
|
47
|
+
(n) => rawOf(n.path) === raw || (idForm !== undefined && n.id === idForm),
|
|
48
|
+
)
|
|
49
|
+
if (idx < 0 && nodes.length > 0) {
|
|
50
|
+
let min = Number.POSITIVE_INFINITY
|
|
51
|
+
nodes.forEach((n, i) => {
|
|
52
|
+
const depth = rawOf(n.path).split('/').length
|
|
53
|
+
if (depth < min) {
|
|
54
|
+
min = depth
|
|
55
|
+
idx = i
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const root = idx >= 0 ? nodes[idx] : undefined
|
|
61
|
+
const children = idx >= 0 ? nodes.filter((_, i) => i !== idx) : [...nodes]
|
|
62
|
+
return { root, children }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The children cursor of a single-root `function.get` result, if the page overflowed. */
|
|
66
|
+
export function childrenCursor(result: GetResultWire): string | undefined {
|
|
67
|
+
const next = result.next
|
|
68
|
+
if (!next) return undefined
|
|
69
|
+
return Object.values(next)[0]?.children
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip a fully-qualified prop key to its leaf name. Node props travel under
|
|
74
|
+
* qualified keys (`<domain>:class.X.property.name` / `<domain>:interface.I.property.name`);
|
|
75
|
+
* this recovers `name`.
|
|
76
|
+
*/
|
|
77
|
+
export function unqualifyKey(key: string): string {
|
|
78
|
+
const dot = key.lastIndexOf('.property.')
|
|
79
|
+
if (dot >= 0) return key.slice(dot + '.property.'.length)
|
|
80
|
+
const slash = key.lastIndexOf('/')
|
|
81
|
+
return slash >= 0 ? key.slice(slash + 1) : key
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read a node prop by its unqualified leaf name (exact key wins, else a qualified match). */
|
|
85
|
+
export function nodeProp(
|
|
86
|
+
node: { props: Record<string, unknown> } | null | undefined,
|
|
87
|
+
name: string,
|
|
88
|
+
): unknown {
|
|
89
|
+
const props = node?.props
|
|
90
|
+
if (!props) return undefined
|
|
91
|
+
if (name in props) return props[name]
|
|
92
|
+
for (const [k, v] of Object.entries(props)) {
|
|
93
|
+
if (unqualifyKey(k) === name) return v
|
|
94
|
+
}
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
package/src/kernel/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
withKernelClient,
|
|
3
|
+
withAdminKernelClient,
|
|
4
|
+
resolveKernelTarget,
|
|
5
|
+
type ClientContext,
|
|
6
|
+
type ResolvedKernelTarget,
|
|
7
|
+
} from './client'
|
|
2
8
|
export { resolveCredential } from './auth'
|
|
3
9
|
export { formatKernelError } from './errors'
|
|
4
10
|
export {
|
|
@@ -9,6 +15,15 @@ export {
|
|
|
9
15
|
resolveSelfIdLazy,
|
|
10
16
|
type SelfExpansionMeta,
|
|
11
17
|
} from './expand'
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
18
|
+
export { runKernelCommand } from './run'
|
|
19
|
+
export { bindGraph, splitRoot, childrenCursor, nodeProp, unqualifyKey } from './graph'
|
|
20
|
+
export type {
|
|
21
|
+
GetResultWire,
|
|
22
|
+
GraphApi,
|
|
23
|
+
GraphNode,
|
|
24
|
+
GraphNodeWire,
|
|
25
|
+
MutationResultWire,
|
|
26
|
+
PatchInput,
|
|
27
|
+
QueryASTInput,
|
|
28
|
+
} from './graph'
|
|
14
29
|
export type { KernelCommandOpts, CallCommandOpts } from './types'
|
package/src/kernel/options.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const KERNEL_PASSTHROUGH_OPTIONS: CommandOption[] = [
|
|
|
15
15
|
flags: '-i, --instance <name>',
|
|
16
16
|
description: 'Target a specific instance (overrides active)',
|
|
17
17
|
},
|
|
18
|
-
{ flags: '--timeout <ms>', description: 'Request timeout in ms
|
|
18
|
+
{ flags: '--timeout <ms>', description: 'Request timeout in ms (default: 30000)' },
|
|
19
19
|
{ flags: '--as <identity>', description: 'Call as a specific identity' },
|
|
20
20
|
{ flags: '--creds <token>', description: 'Use a pre-signed credential (e.g. delegation token)' },
|
|
21
21
|
{ flags: '--debug', description: 'Print full error diagnostics on failure' },
|
package/src/kernel/run.ts
CHANGED
|
@@ -48,16 +48,3 @@ export async function runKernelCommand<T>(run: RunOpts<T>): Promise<void> {
|
|
|
48
48
|
process.exit(1)
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Normalize a `listChildren` response into an array of items.
|
|
54
|
-
* The kernel may return a bare array or `{ items: [...] }`.
|
|
55
|
-
*/
|
|
56
|
-
export function extractItems<T = Record<string, unknown>>(result: unknown): T[] {
|
|
57
|
-
if (Array.isArray(result)) return result as T[]
|
|
58
|
-
if (result && typeof result === 'object') {
|
|
59
|
-
const items = (result as { items?: unknown }).items
|
|
60
|
-
if (Array.isArray(items)) return items as T[]
|
|
61
|
-
}
|
|
62
|
-
return []
|
|
63
|
-
}
|
|
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
3
3
|
import type { AstraleConfig } from '../config'
|
|
4
4
|
import type { InstanceStore } from '../instance'
|
|
5
5
|
|
|
6
|
+
import { AstraleError } from '../../errors'
|
|
6
7
|
import { DEFAULT_CONFIG } from '../config'
|
|
7
8
|
import {
|
|
8
9
|
couldBeConfiguredAdminInstance,
|
|
@@ -181,3 +182,36 @@ describe('instance target helpers', () => {
|
|
|
181
182
|
expect(isManagedInstanceNotFound(auth)).toBe(false)
|
|
182
183
|
})
|
|
183
184
|
})
|
|
185
|
+
|
|
186
|
+
describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
|
|
187
|
+
test('NOT_FOUND-prefixed InternalKernelError is an instance miss', () => {
|
|
188
|
+
const e = new Error('NOT_FOUND: no instance node for "knowledge"')
|
|
189
|
+
e.name = 'InternalKernelError'
|
|
190
|
+
expect(isManagedInstanceNotFound(e)).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
test('other InternalKernelError messages are not swallowed', () => {
|
|
193
|
+
const e = new Error('DISPATCH_FAILED: handler crashed')
|
|
194
|
+
e.name = 'InternalKernelError'
|
|
195
|
+
expect(isManagedInstanceNotFound(e)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
test('resolveNamedInstanceTarget maps the wrap to typed INSTANCE_NOT_FOUND', async () => {
|
|
198
|
+
const managed = async () => {
|
|
199
|
+
const e = new Error('NOT_FOUND: no instance node for "ghost"')
|
|
200
|
+
e.name = 'InternalKernelError'
|
|
201
|
+
throw e
|
|
202
|
+
}
|
|
203
|
+
const opts = {
|
|
204
|
+
config: DEFAULT_CONFIG,
|
|
205
|
+
instances: { instances: {} },
|
|
206
|
+
managed,
|
|
207
|
+
} as unknown as Parameters<typeof resolveInstanceTarget>[1]
|
|
208
|
+
let caught: unknown
|
|
209
|
+
try {
|
|
210
|
+
await resolveInstanceTarget({ source: 'name', name: 'ghost' }, opts)
|
|
211
|
+
} catch (e) {
|
|
212
|
+
caught = e
|
|
213
|
+
}
|
|
214
|
+
expect(caught).toBeInstanceOf(AstraleError)
|
|
215
|
+
expect((caught as AstraleError).code).toBe('INSTANCE_NOT_FOUND')
|
|
216
|
+
})
|
|
217
|
+
})
|