@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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Domain Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Cqz3Oy_B.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BcejyJpa.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -53,7 +53,11 @@ export async function runAsk(
|
|
|
53
53
|
const settings = readSettings(handle.root)
|
|
54
54
|
const envResult = await resolveHarnessEnv(handle.root)
|
|
55
55
|
if (!envResult.ok)
|
|
56
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
text: '',
|
|
58
|
+
isError: true,
|
|
59
|
+
errorMessage: `model gateway auth failed — ${envResult.error}`,
|
|
60
|
+
}
|
|
57
61
|
|
|
58
62
|
return harness.ask({
|
|
59
63
|
root: handle.root,
|
|
@@ -320,7 +320,11 @@ export class ClaudeCodeHarness implements AgentHarness {
|
|
|
320
320
|
const args = ['-p', '--output-format', 'stream-json', '--verbose', ...EXTRA_ARGS]
|
|
321
321
|
let child: ReturnType<typeof spawn>
|
|
322
322
|
try {
|
|
323
|
-
child = spawn(BIN, args, {
|
|
323
|
+
child = spawn(BIN, args, {
|
|
324
|
+
cwd: root,
|
|
325
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
326
|
+
env: childEnv(env),
|
|
327
|
+
})
|
|
324
328
|
} catch (e) {
|
|
325
329
|
resolve({
|
|
326
330
|
ok: false,
|
|
@@ -416,7 +420,11 @@ export class ClaudeCodeHarness implements AgentHarness {
|
|
|
416
420
|
let errorMessage: string | undefined
|
|
417
421
|
let stderr = ''
|
|
418
422
|
|
|
419
|
-
const child = spawn(BIN, args, {
|
|
423
|
+
const child = spawn(BIN, args, {
|
|
424
|
+
cwd: root,
|
|
425
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
426
|
+
env: childEnv(env),
|
|
427
|
+
})
|
|
420
428
|
|
|
421
429
|
const onAbort = () => {
|
|
422
430
|
try {
|
|
@@ -595,7 +603,11 @@ export class ClaudeCodeHarness implements AgentHarness {
|
|
|
595
603
|
let isError = false
|
|
596
604
|
let errorMessage: string | undefined
|
|
597
605
|
|
|
598
|
-
const child = spawn(BIN, args, {
|
|
606
|
+
const child = spawn(BIN, args, {
|
|
607
|
+
cwd: root,
|
|
608
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
609
|
+
env: childEnv(env),
|
|
610
|
+
})
|
|
599
611
|
const onAbort = () => {
|
|
600
612
|
try {
|
|
601
613
|
child.kill('SIGTERM')
|
package/studio/server/api.ts
CHANGED
|
@@ -47,13 +47,6 @@ import {
|
|
|
47
47
|
import { isEnvName, readEnvModel, writeEnvUpdates } from './state/env'
|
|
48
48
|
import { detectGit } from './state/git'
|
|
49
49
|
import { refreshAuto } from './state/handoff'
|
|
50
|
-
import {
|
|
51
|
-
activeInstanceName,
|
|
52
|
-
instanceStatus,
|
|
53
|
-
listInstances,
|
|
54
|
-
runDeploy,
|
|
55
|
-
setActiveInstance,
|
|
56
|
-
} from './state/instance'
|
|
57
50
|
import {
|
|
58
51
|
clearHarnessGateway,
|
|
59
52
|
gatewayAudience,
|
|
@@ -62,6 +55,13 @@ import {
|
|
|
62
55
|
setHarnessGateway,
|
|
63
56
|
} from './state/harness-gateway'
|
|
64
57
|
import { setHostToken } from './state/harness-token'
|
|
58
|
+
import {
|
|
59
|
+
activeInstanceName,
|
|
60
|
+
instanceStatus,
|
|
61
|
+
listInstances,
|
|
62
|
+
runDeploy,
|
|
63
|
+
setActiveInstance,
|
|
64
|
+
} from './state/instance'
|
|
65
65
|
import { deleteIntegration, readIntegrations, upsertIntegration } from './state/integrations'
|
|
66
66
|
import { readLayout, resetLayout, saveLayout, setNodePositions } from './state/layout'
|
|
67
67
|
import { readSettings, updateSettings } from './state/settings'
|
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from 'node:path'
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* import in runtime/index.ts; do NOT assume a folder/name convention).
|
|
7
|
-
* - sourceSpans: file:line + JSDoc for each class/interface/edge/prop/method,
|
|
8
|
-
* keyed by anchor ref (class.X / class.X.property.y / class.X.method.m / …).
|
|
9
|
-
* - annotations: sharp-edge hints (ENUM_DROPPED_BY_UPDATE).
|
|
10
|
-
*
|
|
11
|
-
* Implementation notes:
|
|
12
|
-
* - We open files with a throwaway ts-morph Project (no tsconfig, no type
|
|
13
|
-
* checker required) so this stays cheap and tolerant of broken trees.
|
|
14
|
-
* - Everything degrades gracefully: a missing file/dir yields [] / {}, an
|
|
15
|
-
* unresolvable handler yields `{ unlinked: true }` rather than a wrong guess.
|
|
4
|
+
* ts-morph overlay for IR gaps: handler links, source spans, and annotations.
|
|
5
|
+
* It is tolerant by contract; unresolved files or handlers produce empty/unlinked output.
|
|
16
6
|
*/
|
|
17
7
|
import { Node, Project, SyntaxKind, type CallExpression, type SourceFile } from 'ts-morph'
|
|
18
8
|
|
|
@@ -114,12 +104,7 @@ function stringArg(call: CallExpression, index: number): string | undefined {
|
|
|
114
104
|
|
|
115
105
|
// ───────────────────────────── handler links ─────────────────────────────
|
|
116
106
|
|
|
117
|
-
/**
|
|
118
|
-
* The shape of a "wire one method" call we recognise, after normalising the two
|
|
119
|
-
* fixture styles:
|
|
120
|
-
* my-domain: method(schema, 'Owner', 'name', { authorize, execute })
|
|
121
|
-
* evaluation: classMethods(schema, 'Owner', { name: todo('Owner.name'), … })
|
|
122
|
-
*/
|
|
107
|
+
/** Recognized method-wiring calls after normalizing single-method and grouped helpers. */
|
|
123
108
|
interface WiredMethod {
|
|
124
109
|
owner: string
|
|
125
110
|
method: string
|
|
@@ -136,15 +121,29 @@ const GROUP_HELPERS = new Set([
|
|
|
136
121
|
'remoteInterfaceMethods',
|
|
137
122
|
])
|
|
138
123
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
'
|
|
147
|
-
'
|
|
124
|
+
type KernelToken = { token: string; label?: string }
|
|
125
|
+
|
|
126
|
+
/** Kernel-op idioms surfaced as `kernelCalls`; entries are longest-first. */
|
|
127
|
+
const KERNEL_TOKENS: KernelToken[] = [
|
|
128
|
+
{ token: 'graph.createEdge' },
|
|
129
|
+
{ token: 'graph.removeEdge' },
|
|
130
|
+
{ token: 'function.mutate' },
|
|
131
|
+
{ token: 'graph.children' },
|
|
132
|
+
{ token: 'function.get' },
|
|
133
|
+
{ token: 'graph.create' },
|
|
134
|
+
{ token: 'graph.update' },
|
|
135
|
+
{ token: 'graph.remove' },
|
|
136
|
+
{ token: 'graph.mutate' },
|
|
137
|
+
{ token: 'auth.revoke' },
|
|
138
|
+
{ token: 'graph.links' },
|
|
139
|
+
{ token: 'auth.grant' },
|
|
140
|
+
{ token: 'auth.check' },
|
|
141
|
+
{ token: 'graph.tree' },
|
|
142
|
+
{ token: 'graph.node' },
|
|
143
|
+
{ token: 'revokePerm', label: 'revokePerm (legacy)' },
|
|
144
|
+
{ token: 'checkPerm', label: 'checkPerm (legacy)' },
|
|
145
|
+
{ token: 'graph.get' },
|
|
146
|
+
{ token: 'grantPerm', label: 'grantPerm (legacy)' },
|
|
148
147
|
]
|
|
149
148
|
|
|
150
149
|
/**
|
|
@@ -506,11 +505,11 @@ function scanKernelCalls(file: string): string[] {
|
|
|
506
505
|
}
|
|
507
506
|
const found: string[] = []
|
|
508
507
|
let work = text
|
|
509
|
-
for (const
|
|
510
|
-
if (work.includes(token)) {
|
|
511
|
-
found.push(token)
|
|
508
|
+
for (const entry of KERNEL_TOKENS) {
|
|
509
|
+
if (work.includes(entry.token)) {
|
|
510
|
+
found.push(entry.label ?? entry.token)
|
|
512
511
|
// Blank out matches so `::getLinks` doesn't also count as `::getLink`.
|
|
513
|
-
work = work.split(token).join(' '.repeat(token.length))
|
|
512
|
+
work = work.split(entry.token).join(' '.repeat(entry.token.length))
|
|
514
513
|
}
|
|
515
514
|
}
|
|
516
515
|
return found
|
|
@@ -663,23 +662,78 @@ const DECL_HELPERS: Record<string, 'node' | 'interface' | 'edge'> = {
|
|
|
663
662
|
}
|
|
664
663
|
|
|
665
664
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
665
|
+
* A schema member's true NAME + section, resolved from the `defineSchema` map.
|
|
666
|
+
* The declaration helpers (nodeClass/nodeInterface/edgeClass) carry no name — a
|
|
667
|
+
* member is named by the KEY it is registered under, not by its variable — and a
|
|
668
|
+
* domain may register a class and an interface under the SAME name (the
|
|
669
|
+
* intentional same-name pattern: the `iUser` interface alongside the `User`
|
|
670
|
+
* class). So the map is the sole authority for both the anchor name and the
|
|
671
|
+
* interface-vs-class distinction; the variable identifier alone tells us neither.
|
|
669
672
|
*/
|
|
670
|
-
|
|
673
|
+
interface MemberName {
|
|
674
|
+
schemaName: string
|
|
675
|
+
section: 'interface' | 'class' // the `classes` map also holds edge classes
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Map each registered member VARIABLE (as referenced in `defineSchema`) to its
|
|
679
|
+
* schema name + section, from the domain's own schema files — never node_modules
|
|
680
|
+
* (a dependency's `defineSchema` is not this domain's). */
|
|
681
|
+
function buildMemberNameMap(files: SourceFile[]): Map<string, MemberName> {
|
|
682
|
+
const map = new Map<string, MemberName>()
|
|
683
|
+
for (const sf of files) {
|
|
684
|
+
if (sf.getFilePath().includes('/node_modules/')) continue
|
|
685
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
686
|
+
if (calleeName(call) !== 'defineSchema') continue
|
|
687
|
+
const cfg = call.getArguments()[1]
|
|
688
|
+
if (!cfg || !Node.isObjectLiteralExpression(cfg)) continue
|
|
689
|
+
collectSchemaSection(map, cfg, 'interfaces', 'interface')
|
|
690
|
+
collectSchemaSection(map, cfg, 'classes', 'class')
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return map
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Record `variable → { schemaName, section }` for one `defineSchema` section
|
|
697
|
+
* (`interfaces` / `classes`), handling both `Key: alias` and shorthand `Key`. */
|
|
698
|
+
function collectSchemaSection(
|
|
699
|
+
map: Map<string, MemberName>,
|
|
700
|
+
cfg: Node,
|
|
701
|
+
prop: 'interfaces' | 'classes',
|
|
702
|
+
section: 'interface' | 'class',
|
|
703
|
+
): void {
|
|
704
|
+
const obj = getObjectProp(cfg, prop)
|
|
705
|
+
if (!obj) return
|
|
706
|
+
for (const p of obj.getProperties()) {
|
|
707
|
+
if (Node.isShorthandPropertyAssignment(p)) {
|
|
708
|
+
map.set(p.getName(), { schemaName: p.getName(), section })
|
|
709
|
+
} else if (Node.isPropertyAssignment(p)) {
|
|
710
|
+
const init = p.getInitializer()
|
|
711
|
+
if (init && Node.isIdentifier(init)) {
|
|
712
|
+
map.set(init.getText(), { schemaName: p.getName(), section })
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The anchor namespace ('class' | 'interface' | 'edge') for a declared member.
|
|
720
|
+
* The `defineSchema` SECTION is authoritative for interface-vs-class — a
|
|
721
|
+
* `nodeClass` whose name collides with a same-named interface must still anchor
|
|
722
|
+
* as a class. Within the class section, an `edgeClass` (or an IR edge type)
|
|
723
|
+
* anchors as 'edge'. Falls back to the declaration helper when the member isn't
|
|
724
|
+
* in a parseable `defineSchema` map (e.g. an imported kernel member).
|
|
725
|
+
*/
|
|
726
|
+
function resolveMemberKind(
|
|
671
727
|
ir: SchemaIR | null,
|
|
672
728
|
name: string,
|
|
729
|
+
section: 'interface' | 'class' | undefined,
|
|
673
730
|
helperKind: 'node' | 'interface' | 'edge',
|
|
674
731
|
): 'class' | 'interface' | 'edge' {
|
|
675
|
-
if (
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (cls) return cls.type === 'edge' ? 'edge' : 'class'
|
|
679
|
-
}
|
|
732
|
+
if (section === 'interface') return 'interface'
|
|
733
|
+
const isEdge = helperKind === 'edge' || ir?.classes?.[name]?.type === 'edge'
|
|
734
|
+
if (section === 'class') return isEdge ? 'edge' : 'class'
|
|
680
735
|
if (helperKind === 'interface') return 'interface'
|
|
681
|
-
|
|
682
|
-
return 'class'
|
|
736
|
+
return isEdge ? 'edge' : 'class'
|
|
683
737
|
}
|
|
684
738
|
|
|
685
739
|
export function buildSourceSpans(args: {
|
|
@@ -703,10 +757,14 @@ export function buildSourceSpans(args: {
|
|
|
703
757
|
|
|
704
758
|
const spans: Record<string, SourceSpan> = {}
|
|
705
759
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
760
|
+
const sourceFiles = files.map((f) => project.getSourceFile(f)).filter((f): f is SourceFile => !!f)
|
|
761
|
+
// The domain's `defineSchema` map is authoritative for each member's name +
|
|
762
|
+
// interface-vs-class kind, so an aliased interface (`iUser`→`User`) and a class
|
|
763
|
+
// sharing its name (`User`) each anchor correctly instead of colliding.
|
|
764
|
+
const memberNames = buildMemberNameMap(sourceFiles)
|
|
765
|
+
|
|
766
|
+
for (const sf of sourceFiles) {
|
|
767
|
+
const fileRel = relToRoot(domainRoot, sf.getFilePath())
|
|
710
768
|
|
|
711
769
|
for (const v of sf.getVariableDeclarations()) {
|
|
712
770
|
if (!v.isExported()) continue
|
|
@@ -715,9 +773,9 @@ export function buildSourceSpans(args: {
|
|
|
715
773
|
const helper = calleeName(init)
|
|
716
774
|
if (!helper || !(helper in DECL_HELPERS)) continue
|
|
717
775
|
const helperKind = DECL_HELPERS[helper]
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
const ns =
|
|
776
|
+
const member = memberNames.get(v.getName())
|
|
777
|
+
const name = member?.schemaName ?? v.getName()
|
|
778
|
+
const ns = resolveMemberKind(ir, name, member?.section, helperKind)
|
|
721
779
|
|
|
722
780
|
const stmt = v.getVariableStatement() ?? v
|
|
723
781
|
spans[`${ns}.${name}`] = makeSpan(domainRoot, fileRel, stmt, v)
|
|
@@ -729,7 +787,7 @@ export function buildSourceSpans(args: {
|
|
|
729
787
|
}
|
|
730
788
|
|
|
731
789
|
// Edges: endpoints are the first two args; props live in the third.
|
|
732
|
-
if (
|
|
790
|
+
if (ns === 'edge') {
|
|
733
791
|
collectEdge(spans, name, init, domainRoot, fileRel)
|
|
734
792
|
}
|
|
735
793
|
}
|
|
@@ -45,7 +45,8 @@ function normalize(input: any): HarnessGatewayConfig {
|
|
|
45
45
|
const model = typeof input?.model === 'string' ? input.model.trim() : ''
|
|
46
46
|
// back-compat: a pre-union config stored only `apiKey` → treat as a static token
|
|
47
47
|
const authInput =
|
|
48
|
-
input?.auth ??
|
|
48
|
+
input?.auth ??
|
|
49
|
+
(typeof input?.apiKey === 'string' ? { mode: 'token', token: input.apiKey } : undefined)
|
|
49
50
|
return {
|
|
50
51
|
enabled: input?.enabled === true,
|
|
51
52
|
baseUrl: typeof input?.baseUrl === 'string' ? input.baseUrl.trim() : '',
|
|
@@ -86,7 +87,12 @@ export function getHarnessGatewayState(root: string): HarnessGatewayState {
|
|
|
86
87
|
const local = readLocal(root)
|
|
87
88
|
const global = readGlobal()
|
|
88
89
|
if (local) {
|
|
89
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
local,
|
|
92
|
+
global,
|
|
93
|
+
effective: local.enabled ? local : null,
|
|
94
|
+
source: local.enabled ? 'domain' : 'none',
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
return {
|
|
92
98
|
local: null,
|
|
@@ -104,7 +110,10 @@ export interface SetHarnessGatewayInput {
|
|
|
104
110
|
/** Persist the config to the chosen scope. Writing GLOBAL also clears any local
|
|
105
111
|
* override so this domain (and every un-overridden one) inherits it — that is the
|
|
106
112
|
* "apply to all domains" intent. */
|
|
107
|
-
export function setHarnessGateway(
|
|
113
|
+
export function setHarnessGateway(
|
|
114
|
+
root: string,
|
|
115
|
+
input: SetHarnessGatewayInput,
|
|
116
|
+
): HarnessGatewayState {
|
|
108
117
|
const cfg = normalize(input.config)
|
|
109
118
|
if (input.scope === 'global') {
|
|
110
119
|
writeGlobal(cfg)
|
|
Binary file
|
|
@@ -14,7 +14,11 @@ export interface VisibilityState {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const FILE = 'visibility.json'
|
|
17
|
-
const DEFAULT: VisibilityState = {
|
|
17
|
+
const DEFAULT: VisibilityState = {
|
|
18
|
+
hidden: {},
|
|
19
|
+
showInheritedEdges: true,
|
|
20
|
+
materializedInterfaces: {},
|
|
21
|
+
}
|
|
18
22
|
|
|
19
23
|
export function readVisibility(root: string): VisibilityState {
|
|
20
24
|
// merge over DEFAULT so a file written before a field existed (e.g. pre-materialize)
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { FnMap } from '@astrale-os/kernel-client'
|
|
2
|
-
import type { ClientSession } from '@astrale-os/kernel-client/session'
|
|
3
|
-
|
|
4
|
-
import { describe, expect, test } from 'bun:test'
|
|
5
|
-
|
|
6
|
-
import { mintDelegationPath } from '../remote-routing'
|
|
7
|
-
|
|
8
|
-
function unsignedJwt(payload: Record<string, unknown>): string {
|
|
9
|
-
const encode = (value: unknown) =>
|
|
10
|
-
Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=+$/, '')
|
|
11
|
-
return `${encode({ alg: 'none' })}.${encode(payload)}.signature`
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** A ClientSession stub that records call paths and returns a canned whoami node. */
|
|
15
|
-
function stubClient(whoamiNode: unknown): {
|
|
16
|
-
client: ClientSession<FnMap>
|
|
17
|
-
calls: Array<{ path: string }>
|
|
18
|
-
} {
|
|
19
|
-
const calls: Array<{ path: string }> = []
|
|
20
|
-
const client = {
|
|
21
|
-
call: async (path: string) => {
|
|
22
|
-
calls.push({ path })
|
|
23
|
-
return whoamiNode
|
|
24
|
-
},
|
|
25
|
-
} as unknown as ClientSession<FnMap>
|
|
26
|
-
return { client, calls }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('mintDelegationPath — caller self-anchor resolution', () => {
|
|
30
|
-
test('resolves an IdP identity to its NODE id via whoami (not the JWT sub)', async () => {
|
|
31
|
-
// A WorkOS subject is the upstream user id, NOT the kernel node id — `@<sub>`
|
|
32
|
-
// would 404. whoami returns the caller's own identity node; its id is the anchor.
|
|
33
|
-
const { client, calls } = stubClient({ id: 'ef280077-6039-4a1a-9d06-403718d8e112' })
|
|
34
|
-
const credential = unsignedJwt({ sub: 'user_01KC9MW1M6S5J6V9ERSRJ8RDYF' })
|
|
35
|
-
|
|
36
|
-
expect(await mintDelegationPath(client, credential)).toBe(
|
|
37
|
-
'@ef280077-6039-4a1a-9d06-403718d8e112::mintDelegationCredential',
|
|
38
|
-
)
|
|
39
|
-
expect(calls).toEqual([{ path: '/:kernel.astrale.ai:interface.Identity:whoami' }])
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
test('legacy system subject maps to the __system__ graph node WITHOUT a whoami call', async () => {
|
|
43
|
-
const { client, calls } = stubClient({ id: 'unused' })
|
|
44
|
-
expect(await mintDelegationPath(client, unsignedJwt({ sub: 'system' }))).toBe(
|
|
45
|
-
'@__system__::mintDelegationCredential',
|
|
46
|
-
)
|
|
47
|
-
expect(calls).toEqual([])
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
test('opaque non-JWT credential falls back to __system__ (no whoami)', async () => {
|
|
51
|
-
const { client, calls } = stubClient({ id: 'unused' })
|
|
52
|
-
expect(await mintDelegationPath(client, 'not-a-jwt')).toBe(
|
|
53
|
-
'@__system__::mintDelegationCredential',
|
|
54
|
-
)
|
|
55
|
-
expect(calls).toEqual([])
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('throws when whoami returns no id (cannot address the caller)', async () => {
|
|
59
|
-
const { client } = stubClient({ notAnId: true })
|
|
60
|
-
await expect(mintDelegationPath(client, unsignedJwt({ sub: 'abc' }))).rejects.toThrow(
|
|
61
|
-
/could not resolve the caller identity/i,
|
|
62
|
-
)
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
// `lookupRemoteBinding` (the proactive `<path>::get` resolver) was removed: the
|
|
67
|
-
// kernel now carries the worker's `iss` on the redirect and the ClientSession
|
|
68
|
-
// follows it reactively, minting via `mintRemoteCredential` (the cache mint
|
|
69
|
-
// wired in `kernel/client.ts`). The slug→iss audience derivation it tested now
|
|
70
|
-
// lives in the kernel resolver (`runtime/__tests__/resolver-remote.test.ts`).
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { FnMap } from '@astrale-os/kernel-client'
|
|
2
|
-
import type { ClientSession } from '@astrale-os/kernel-client/session'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Credential-minting helpers for remote-bound kernel calls.
|
|
6
|
-
*
|
|
7
|
-
* The CLI no longer resolves remote bindings client-side: the kernel emits a
|
|
8
|
-
* redirect carrying the target worker's `iss`, and the `ClientSession` follows
|
|
9
|
-
* it, minting a worker-scoped delegation via `mintRemoteCredential` (wired as
|
|
10
|
-
* the session's delegation-cache mint in `client.ts`). These helpers (whoami →
|
|
11
|
-
* self-delegation) are the mint itself.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Mint a delegation credential scoped to the remote worker's audience.
|
|
16
|
-
*
|
|
17
|
-
* The Function lives under the caller's self identity (`@__system__` in manager
|
|
18
|
-
* kernels), so the invariant on `mintDelegationCredential` passes without
|
|
19
|
-
* additional claims. A self-delegation is the least-privilege shape — the
|
|
20
|
-
* worker inherits whatever grants the caller already has.
|
|
21
|
-
*
|
|
22
|
-
* Internal calls pass `skipDelegation: true`: they target the default kernel
|
|
23
|
-
* (same origin), so the delegation cache would skip them anyway, but being
|
|
24
|
-
* explicit guarantees the mint can never recurse into itself.
|
|
25
|
-
*/
|
|
26
|
-
export async function mintRemoteCredential(
|
|
27
|
-
client: ClientSession<FnMap>,
|
|
28
|
-
audience: string,
|
|
29
|
-
callerCredential: string,
|
|
30
|
-
): Promise<string> {
|
|
31
|
-
const mintPath = await mintDelegationPath(client, callerCredential)
|
|
32
|
-
const result = await client.call(
|
|
33
|
-
mintPath,
|
|
34
|
-
{
|
|
35
|
-
audience,
|
|
36
|
-
delegation: { kind: 'identity', self: true },
|
|
37
|
-
ttl: 3600,
|
|
38
|
-
},
|
|
39
|
-
{ credential: callerCredential, skipDelegation: true },
|
|
40
|
-
)
|
|
41
|
-
if (typeof result !== 'string') {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`mintDelegationCredential returned non-string: ${typeof result} — cannot use as credential`,
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
return result
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Resolve the kernel path that mints a self-delegation credential for the
|
|
51
|
-
* CALLER: `@<nodeId>::mintDelegationCredential`.
|
|
52
|
-
*/
|
|
53
|
-
export async function mintDelegationPath(
|
|
54
|
-
client: ClientSession<FnMap>,
|
|
55
|
-
credential: string,
|
|
56
|
-
): Promise<string> {
|
|
57
|
-
const sub = readJwtSub(credential)
|
|
58
|
-
// System / opaque credential: the graph node id is literally `__system__`,
|
|
59
|
-
// so we can skip the whoami round-trip.
|
|
60
|
-
if (!sub || sub === 'system') return '@__system__::mintDelegationCredential'
|
|
61
|
-
const self = (await client.call(
|
|
62
|
-
'/:kernel.astrale.ai:interface.Identity:whoami',
|
|
63
|
-
{},
|
|
64
|
-
{ credential, skipDelegation: true },
|
|
65
|
-
)) as { id?: unknown } | null
|
|
66
|
-
const id = self && typeof self.id === 'string' ? self.id : undefined
|
|
67
|
-
if (!id) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
'Could not resolve the caller identity (whoami returned no id) — cannot mint a delegation credential.',
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
return `@${id}::mintDelegationCredential`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function readJwtSub(credential: string): string | null {
|
|
76
|
-
const [, payload] = credential.split('.')
|
|
77
|
-
if (!payload) return null
|
|
78
|
-
try {
|
|
79
|
-
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
|
|
80
|
-
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
81
|
-
const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as {
|
|
82
|
-
sub?: unknown
|
|
83
|
-
}
|
|
84
|
-
return typeof decoded.sub === 'string' && decoded.sub.length > 0 ? decoded.sub : null
|
|
85
|
-
} catch {
|
|
86
|
-
return null
|
|
87
|
-
}
|
|
88
|
-
}
|