@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
@@ -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-wtU0Zxhy.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-DOwzZAEK.css">
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 { text: '', isError: true, errorMessage: `model gateway auth failed — ${envResult.error}` }
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, { cwd: root, stdio: ['pipe', 'pipe', 'ignore'], env: childEnv(env) })
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, { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv(env) })
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, { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv(env) })
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')
@@ -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
- * overlay-tsmorph.ts — ts-morph extraction of the things the IR cannot carry:
5
- * - handlerLinks: schema method runtime handler file (follow the `execute`
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
- /** Kernel-op tokens we surface as `kernelCalls`, longest-first to avoid overlap. */
140
- const KERNEL_TOKENS = [
141
- '::getLinks',
142
- '::getLink',
143
- '::update',
144
- '::link',
145
- '::create',
146
- 'createNode',
147
- 'grantPerm',
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 token of KERNEL_TOKENS) {
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
- * Decide the anchor namespace ('class' | 'interface' | 'edge') for a declared
667
- * name, preferring the IR's authority (an edgeClass is a class whose IR type is
668
- * 'edge'); fall back to the declaration helper used.
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
- function anchorKindFor(
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 (ir) {
676
- if (ir.interfaces?.[name]) return 'interface'
677
- const cls = ir.classes?.[name]
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
- if (helperKind === 'edge') return 'edge'
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
- for (const filePath of files) {
707
- const sf = project.getSourceFile(filePath)
708
- if (!sf) continue
709
- const fileRel = relToRoot(domainRoot, filePath)
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 name = v.getName()
719
- const kind = anchorKindFor(ir, name, helperKind)
720
- const ns = kind // 'class' | 'interface' | 'edge'
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 (kind === 'edge') {
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 ?? (typeof input?.apiKey === 'string' ? { mode: 'token', token: input.apiKey } : undefined)
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 { local, global, effective: local.enabled ? local : null, source: local.enabled ? 'domain' : 'none' }
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(root: string, input: SetHarnessGatewayInput): HarnessGatewayState {
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)
@@ -14,7 +14,11 @@ export interface VisibilityState {
14
14
  }
15
15
 
16
16
  const FILE = 'visibility.json'
17
- const DEFAULT: VisibilityState = { hidden: {}, showInheritedEdges: true, materializedInterfaces: {} }
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
- }