@frontera-sdk/cli 1.53.4 → 1.53.6

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 (45) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/platform-api.ts +20 -0
  4. package/src/commands/app/checks.ts +1 -1
  5. package/src/commands/app/deploy.ts +9 -9
  6. package/src/commands/app/deployments.ts +1 -1
  7. package/src/commands/app/dev.ts +79 -14
  8. package/src/commands/app/promote.ts +1 -1
  9. package/src/commands/app/pull.ts +4 -2
  10. package/src/commands/app/rollback.ts +1 -1
  11. package/src/commands/app/save.ts +3 -7
  12. package/src/commands/app/shared.ts +59 -2
  13. package/src/commands/app/unpin.ts +1 -1
  14. package/src/commands/app/versions.ts +1 -1
  15. package/src/commands/knowledge/index-commands.ts +15 -2
  16. package/src/commands/local/check.ts +21 -13
  17. package/src/commands/local/data.ts +1 -1
  18. package/src/commands/local/index-commands.ts +1 -1
  19. package/src/commands/local/snapshot.ts +2 -2
  20. package/src/commands/local/sync-request.ts +17 -3
  21. package/src/commands/local/sync.ts +4 -1
  22. package/src/commands/mcp-server/index-commands.ts +99 -11
  23. package/src/commands/registry.ts +1 -1
  24. package/src/flag-help.ts +2 -1
  25. package/src/forge/permission-rules.ts +2 -0
  26. package/src/local/local-app-runtime.ts +6 -4
  27. package/src/local/local-build-inputs.ts +2 -1
  28. package/src/local/local-db-snapshot.ts +62 -46
  29. package/src/local/local-docker.ts +84 -10
  30. package/src/local/local-mcp-servers.ts +79 -14
  31. package/src/local/local-person-steps.ts +29 -11
  32. package/src/local/local-project.ts +5 -1
  33. package/src/local/local-supervisor.ts +41 -3
  34. package/src/local/local-sync-remote.ts +18 -0
  35. package/src/main.ts +6 -1
  36. package/src/mcp/mcp-apps.ts +96 -20
  37. package/src/mcp/tools/agent-tools.ts +29 -7
  38. package/src/mcp/tools/app-tools.ts +3 -3
  39. package/src/mcp/tools/project-tools.ts +2 -1
  40. package/src/project.ts +47 -11
  41. package/src/remote-guard.ts +4 -1
  42. package/src/templates/next-app-files.ts +4 -1
  43. package/src/templates/next-skills.ts +9 -5
  44. package/src/vendor/kit-assets.json +6 -6
  45. package/src/vendor/sdk-sources.json +17 -17
@@ -1,5 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process'
2
2
  import { openSync, readFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
3
5
 
4
6
  /**
5
7
  * The container runtime, through its CLI.
@@ -67,6 +69,80 @@ export function imagePresent(image: string): boolean {
67
69
  return docker(['image', 'inspect', image, '--format', '{{.Id}}']).ok
68
70
  }
69
71
 
72
+ /** The registry an image comes from: its first part when that names a host, else Docker Hub. */
73
+ export function registryHost(image: string): string {
74
+ const first = image.split('/')[0]!
75
+ return image.includes('/') && /[.:]|^localhost$/.test(first) ? first : 'docker.io'
76
+ }
77
+
78
+ /** Docker Hub answers to three names, and its sign-in is kept under `https://index.docker.io/v1/`. */
79
+ const DOCKER_HUB = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io'])
80
+ const sameRegistry = (host: string) => (DOCKER_HUB.has(host) ? 'docker.io' : host)
81
+
82
+ /**
83
+ * Whether Docker holds a sign-in for a registry host, read as Docker reads it:
84
+ * `config.json` in `$DOCKER_CONFIG` (else `~/.docker`), then the host's
85
+ * credential helper or the default store, asked for its `list` (addresses and
86
+ * user names, never a secret), else the file's own `auths`. False when none
87
+ * is found or it can't be read; nothing here prompts.
88
+ */
89
+ export function dockerSignedIn(host: string): boolean {
90
+ let config: { auths?: Record<string, { auth?: string; identitytoken?: string } | null>; credHelpers?: Record<string, string>; credsStore?: string }
91
+ try {
92
+ config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), '.docker'), 'config.json'), 'utf8'))
93
+ } catch {
94
+ return false
95
+ }
96
+ const isHost = (address: string) => sameRegistry(address.replace(/^https?:\/\//, '').split('/')[0]!) === sameRegistry(host)
97
+ const helper = Object.entries(config.credHelpers ?? {}).find(([address]) => isHost(address))?.[1] ?? config.credsStore
98
+ if (!helper) return Object.entries(config.auths ?? {}).some(([address, entry]) => isHost(address) && !!(entry?.auth || entry?.identitytoken))
99
+ // A name with a slash would run a program by path (`../evil` from the folder), not a helper on PATH.
100
+ if (!/^[A-Za-z0-9._-]+$/.test(helper)) return false
101
+ const listed = spawnSync(`docker-credential-${helper}`, ['list'], { input: 'unused', encoding: 'utf8', timeout: 5_000 })
102
+ if (listed.status !== 0) return false
103
+ try {
104
+ return Object.keys(JSON.parse(listed.stdout) as Record<string, string>).some(isHost)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Why a pull failed, decided once from its output and Docker's sign-in for
112
+ * the registry. `signed-out`: refused, with no sign-in Docker shows, so
113
+ * signing in is the fix. `no-access`: refused although Docker is signed in,
114
+ * so only someone who grants access can clear it. `missing`: the image or
115
+ * tag is not there. `other`: anything else, in the log.
116
+ */
117
+ export type PullFailure = 'signed-out' | 'no-access' | 'missing' | 'other'
118
+
119
+ export class PullError extends DockerError {
120
+ constructor(
121
+ readonly failure: PullFailure,
122
+ message: string,
123
+ ) {
124
+ super(message)
125
+ }
126
+ }
127
+
128
+ function pullFailureMessage(failure: PullFailure, image: string, code: number | null, logFile: string): string {
129
+ const host = registryHost(image)
130
+ switch (failure) {
131
+ case 'signed-out':
132
+ return host === 'ghcr.io'
133
+ ? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
134
+ : `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
135
+ case 'no-access':
136
+ return host === 'ghcr.io'
137
+ ? `the registry refused ${image}, though Docker is signed in to ghcr.io. The GitHub account it signed in with has no read access to the organization's packages, or its sign-in no longer works. Ask an organization owner to grant that account read access, or run from a platform checkout without --image`
138
+ : `the registry refused ${image}, though Docker is signed in to ${host}. The account it signed in with may not pull it, or its sign-in no longer works. Ask an owner of ${host} to grant that account pull access, or run from a platform checkout without --image`
139
+ case 'missing':
140
+ return `${image} is not in the registry: check --registry and --version, or push that release there first`
141
+ case 'other':
142
+ return `docker pull ${image} failed (exit ${code}); see ${logFile}`
143
+ }
144
+ }
145
+
70
146
  /** Pull an image, writing progress to a log file instead of the terminal. */
71
147
  export function pullImage(image: string, logFile: string): Promise<void> {
72
148
  return new Promise((resolvePromise, reject) => {
@@ -75,23 +151,21 @@ export function pullImage(image: string, logFile: string): Promise<void> {
75
151
  child.on('error', reject)
76
152
  child.on('exit', (code) => {
77
153
  if (code === 0) return resolvePromise()
78
- // The private release images are the usual failure: say what grants access.
79
154
  let tail = ''
80
155
  try {
81
156
  tail = readFileSync(logFile, 'utf8').slice(-2000)
82
157
  } catch {
83
158
  // The log is a convenience; the exit code is the fact.
84
159
  }
85
- const denied = /unauthorized|denied|authentication required/i.test(tail)
86
- const host = image.split('/')[0]
87
- const reason = denied
88
- ? image.startsWith('ghcr.io/')
89
- ? `the registry refused access to ${image}: run \`gh auth refresh -s read:packages\`, then \`gh auth token | docker login ghcr.io -u <github-user> --password-stdin\`, or run from a platform checkout without --image`
90
- : `the registry refused access to ${image}: sign in with \`docker login ${host}\`, or run from a platform checkout without --image`
160
+ // The words don't tell no sign-in from a sign-in without access (ghcr.io answers `denied` to a token that no longer works); Docker's own sign-in does.
161
+ const failure: PullFailure = /unauthorized|denied|authentication required/i.test(tail)
162
+ ? dockerSignedIn(registryHost(image))
163
+ ? 'no-access'
164
+ : 'signed-out'
91
165
  : /manifest unknown|not found/i.test(tail)
92
- ? `${image} is not in the registry: check --registry and --version, or push that release there first`
93
- : `docker pull ${image} failed (exit ${code}); see ${logFile}`
94
- reject(new DockerError(reason))
166
+ ? 'missing'
167
+ : 'other'
168
+ reject(new PullError(failure, pullFailureMessage(failure, image, code, logFile)))
95
169
  })
96
170
  })
97
171
  }
@@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { parse as parseYaml } from 'yaml'
4
4
 
5
+ import { CliError } from '../errors'
5
6
  import { errorOf } from './local-chat-apps'
7
+ import { allTargets } from './local-dev-sync'
8
+ import { localProjectAt } from './local-project'
9
+ import { getSyncKey, readLink, type SyncLink } from './local-sync-link'
6
10
 
7
11
  /**
8
12
  * A workspace's own MCP servers: `mcp-servers/<slug>.yaml` (or .yml, .json),
@@ -12,6 +16,11 @@ import { errorOf } from './local-chat-apps'
12
16
  * types the workspace is granted, its Functions, workflows and governed
13
17
  * Actions. A server whose file is deleted is archived on the runtime.
14
18
  *
19
+ * A change request carries no MCP servers: `frontera mcp-server save` sends
20
+ * the same file with the same request to the workspace the project is linked
21
+ * to (`local sync connect`), with its workspace key, as `local sync push`
22
+ * reaches it; to the profile's workspace in a project with no link.
23
+ *
15
24
  * Its format is the service's (`frontera local schema mcp-server`).
16
25
  */
17
26
 
@@ -24,45 +33,101 @@ export interface McpServerApplied {
24
33
  output: string
25
34
  }
26
35
 
27
- interface SavedServer {
36
+ export interface SavedServer {
28
37
  server?: { slug?: string; name?: string; url?: string; signIn?: boolean; tools?: unknown[] }
29
38
  created?: boolean
30
39
  warnings?: Array<{ name?: string; reason?: string }>
31
40
  }
32
41
 
33
42
  /** The file as the service takes it: YAML or JSON, a mapping at the top. */
34
- export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; output: string } {
43
+ export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; problem: string } {
35
44
  const path = join(root, file)
36
- if (!existsSync(path)) return { ok: false, output: `error [MCP_SERVER]: ${file} is missing` }
45
+ if (!existsSync(path)) return { ok: false, problem: `${file} is missing` }
37
46
  let doc: unknown
38
47
  try {
39
48
  const text = readFileSync(path, 'utf8')
40
49
  doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
41
50
  } catch (err) {
42
- return { ok: false, output: `error [MCP_SERVER]: ${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
51
+ return { ok: false, problem: `${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
43
52
  }
44
53
  if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
45
- return { ok: false, output: `error [MCP_SERVER]: ${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
54
+ return { ok: false, problem: `${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
46
55
  }
47
56
  return { ok: true, doc: doc as Record<string, unknown> }
48
57
  }
49
58
 
59
+ /** Where a client connects to a saved server, and each tool offered to nobody for now. */
60
+ export function savedLines(slug: string, saved: SavedServer | null, updated = 'applied'): string[] {
61
+ const tools = saved?.server?.tools?.length ?? 0
62
+ const url = saved?.server?.url
63
+ // A local runtime offers no sign-in: a client there sends the runtime's workspace key.
64
+ const how = saved?.server?.signIn === false ? " with the runtime's workspace key" : ''
65
+ const lines = [` ${saved?.created ? 'created' : updated} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}${how}` : ''}`]
66
+ for (const warning of saved?.warnings ?? []) {
67
+ lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
68
+ }
69
+ return lines
70
+ }
71
+
50
72
  /** Save one server on the runtime; the line says where a client connects, or what the service refused. */
51
73
  export async function applyMcpServer(root: string, slug: string, file: string, call: Call): Promise<McpServerApplied> {
52
74
  const read = readMcpServerFile(root, file)
53
- if (!read.ok) return read
75
+ if (!read.ok) return { ok: false, output: `error [MCP_SERVER]: ${read.problem}` }
54
76
  const res = await call('PUT', `/v1/mcp-servers/${encodeURIComponent(slug)}`, read.doc)
55
77
  if (res.status !== 200 && res.status !== 201) {
56
78
  return { ok: false, output: `error [MCP_SERVER]: MCP server ${slug} was refused: ${errorOf(res)}` }
57
79
  }
58
80
  const saved = ((res.body as { data?: unknown } | null)?.data ?? res.body) as SavedServer | null
59
- const tools = saved?.server?.tools?.length ?? 0
60
- const url = saved?.server?.url
61
- // A local runtime offers no sign-in: a client there sends the runtime's workspace key.
62
- const how = saved?.server?.signIn === false ? " with the runtime's workspace key" : ''
63
- const lines = [` ${saved?.created ? 'created' : 'applied'} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}${how}` : ''}`]
64
- for (const warning of saved?.warnings ?? []) {
65
- lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
81
+ return { ok: true, output: savedLines(slug, saved).join('\n') }
82
+ }
83
+
84
+ /** The project's MCP server files, as `local dev` finds them. */
85
+ export function projectMcpServers(root: string): Array<{ slug: string; file: string }> {
86
+ return allTargets(root).flatMap((t) => (t.kind === 'mcp-server' ? [{ slug: t.name, file: t.file }] : []))
87
+ }
88
+
89
+ /** "Orders (ws_1) on acme.frontera.example": the linked workspace in a sentence. */
90
+ function linkedWorkspaceWords(link: SyncLink): string {
91
+ return `${link.workspaceName ?? link.workspace}${link.workspaceName ? ` (${link.workspace})` : ''} on ${new URL(link.platform).host}`
92
+ }
93
+
94
+ /**
95
+ * Where `frontera mcp-server save` sends a linked project's servers: the
96
+ * linked workspace, with the project's workspace key, which is what `local
97
+ * sync push` reaches and what its line names. The directory's profile in a
98
+ * project with a local runtime is the runtime's (`local start` binds it), and
99
+ * `local dev` already saves the files there. A workspace key acts for its
100
+ * creator, so the service lets it save a server as it lets `local dev`'s.
101
+ * Null when the project has no link. With a link and no key on this computer,
102
+ * refused rather than sent to the profile.
103
+ */
104
+ export function linkedSaveTarget(root: string): { platform: string; key: string; where: string } | null {
105
+ const link = readLink(root)
106
+ if (!link) return null
107
+ const workspace = linkedWorkspaceWords(link)
108
+ const key = getSyncKey(localProjectAt(root), 'workspace')
109
+ if (!key) {
110
+ throw new CliError(`this project is linked to ${workspace}, and this computer holds no workspace key for it`, {
111
+ code: 'SYNC_NO_KEY',
112
+ hint: '`frontera local sync connect` stores one (keys are kept per computer); `--profile <name>` saves to that profile’s workspace instead',
113
+ })
66
114
  }
67
- return { ok: true, output: lines.join('\n') }
115
+ return { platform: link.platform, key, where: `${workspace}, the workspace this project is linked to` }
116
+ }
117
+
118
+ /**
119
+ * What a push or `sync status` says of the project's MCP server files, which
120
+ * no change request carries, with the command that saves them. Null when the
121
+ * project has none.
122
+ */
123
+ export function mcpServersNotCarried(root: string): { files: string[]; command: string; line: string } | null {
124
+ const files = projectMcpServers(root).map((s) => s.file)
125
+ if (!files.length) return null
126
+ const command = files.length === 1 ? `frontera mcp-server save ${files[0]}` : 'frontera mcp-server save'
127
+ const link = readLink(root)
128
+ const where = link
129
+ ? `${linkedWorkspaceWords(link)}, the workspace this project is linked to, with its workspace key (\`--profile <name>\` saves to a profile’s workspace instead)`
130
+ : 'the workspace your profile signs in to (`--profile <name>` picks another)'
131
+ const line = `A change request does not carry MCP servers (${files.join(', ')}): \`${command}\` saves ${files.length === 1 ? 'it' : 'every one'} to ${where}.`
132
+ return { files, command, line }
68
133
  }
@@ -25,7 +25,7 @@ import { writeFileAtomic } from '../fs-atomic'
25
25
  export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
26
26
 
27
27
  /** Who or what the step needs. */
28
- export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person'] as const
28
+ export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person', 'registry-owner'] as const
29
29
  export type Credential = (typeof CREDENTIALS)[number]
30
30
 
31
31
  /** Each credential in the person's words, as the card and the list say it. */
@@ -36,6 +36,7 @@ export const CREDENTIAL_WORDS: Record<Credential, string> = {
36
36
  admin: 'a workspace admin',
37
37
  'second-person': 'a second person (not whoever made the change)',
38
38
  person: 'you, signed in (`frontera auth login --browser`)',
39
+ 'registry-owner': 'an owner of the organization that publishes the runtime images',
39
40
  }
40
41
 
41
42
  /**
@@ -67,9 +68,9 @@ export interface PersonStep {
67
68
  doneAt?: string
68
69
  /** Who saw it done: the person's mark, or its check. */
69
70
  doneBy?: 'person' | 'check'
70
- /** Where it came from: the coding agent, or what a change request left after Apply. */
71
- source?: 'agent' | 'change-request'
72
- /** A change request's own key for it (`action-deploy:qualifyLead`), so applying again updates it. */
71
+ /** Where it came from: the coding agent, what a change request left after Apply, or the local runtime at a refusal only a person clears. */
72
+ source?: 'agent' | 'change-request' | 'runtime'
73
+ /** A change request's own key for it (`action-deploy:qualifyLead`), or the runtime's (`image-access:<repository>`), so recording it again updates it. */
73
74
  key?: string
74
75
  check?: StepCheck
75
76
  lastCheckedAt?: string
@@ -133,19 +134,23 @@ function writeSteps(root: string, steps: PersonStep[]): void {
133
134
  }
134
135
 
135
136
  export interface NewPersonStep {
136
- command: string
137
+ /** null for a step done outside the CLI, which only the CLI itself records. */
138
+ command: string | null
137
139
  why: string
138
140
  changes: string
139
141
  credential: string
140
142
  delivery?: string | null
141
143
  /** The read that proves it done, already checked (`checkProblem`). */
142
144
  check?: StepCheck
145
+ /** The CLI's own step, under a stable key: recording it again updates that step, and reopens it if it was settled. */
146
+ key?: string
147
+ source?: 'agent' | 'runtime'
143
148
  }
144
149
 
145
150
  /** Each problem with a step as given; empty when it can be recorded. */
146
151
  export function stepProblems(input: NewPersonStep): string[] {
147
152
  const problems: string[] = []
148
- const command = commandProblem(input.command)
153
+ const command = input.command === null ? null : commandProblem(input.command)
149
154
  if (command) problems.push(command)
150
155
  if (!input.why?.trim()) problems.push('say why the step is needed (`why`)')
151
156
  if (!input.changes?.trim()) problems.push('say what the step changes (`changes`)')
@@ -160,17 +165,20 @@ export function stepProblems(input: NewPersonStep): string[] {
160
165
  /**
161
166
  * Record a step. The same command pending in the same delivery is the same
162
167
  * step, updated rather than listed twice, so an agent restating its plan
163
- * after a compaction doesn't double the card.
168
+ * after a compaction doesn't double the card. A keyed step is one step
169
+ * whatever its state: a refusal seen again is pending again.
164
170
  */
165
171
  export function addPersonStep(root: string, input: NewPersonStep, now = new Date()): { step: PersonStep; updated: boolean } {
166
172
  const problems = stepProblems(input)
167
173
  if (problems.length) throw new Error(problems.join('; '))
168
174
  const steps = readPersonSteps(root)
169
- const command = input.command.trim().replace(/\s+/g, ' ')
175
+ const command = input.command === null ? null : input.command.trim().replace(/\s+/g, ' ')
170
176
  const delivery = input.delivery?.trim() || null
171
- const same = steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
177
+ const same = input.key ? steps.find((s) => s.key === input.key) : steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
172
178
  if (same) {
173
- Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, ...(input.check ? { check: input.check } : {}) })
179
+ Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, status: 'pending', ...(input.check ? { check: input.check } : {}) })
180
+ delete same.doneAt
181
+ delete same.doneBy
174
182
  writeSteps(root, steps)
175
183
  return { step: same, updated: true }
176
184
  }
@@ -184,7 +192,8 @@ export function addPersonStep(root: string, input: NewPersonStep, now = new Date
184
192
  delivery,
185
193
  status: 'pending',
186
194
  createdAt: now.toISOString(),
187
- source: 'agent',
195
+ source: input.source ?? 'agent',
196
+ ...(input.key ? { key: input.key } : {}),
188
197
  ...(input.check ? { check: input.check } : {}),
189
198
  }
190
199
  writeSteps(root, [...steps, step])
@@ -208,6 +217,15 @@ export function settlePersonStep(root: string, id: string, status: 'done' | 'dis
208
217
  return step
209
218
  }
210
219
 
220
+ /** Mark the pending step recorded under `key` done, seen by the CLI itself (the pull it waited for now works). */
221
+ export function markKeyedStepDone(root: string, key: string, now = new Date()): void {
222
+ const steps = readPersonSteps(root)
223
+ const step = steps.find((s) => s.key === key && s.status === 'pending')
224
+ if (!step) return
225
+ Object.assign(step, { status: 'done', doneAt: now.toISOString(), doneBy: 'check' })
226
+ writeSteps(root, steps)
227
+ }
228
+
211
229
  /** Take settled steps off the list; pending ones stay. */
212
230
  export function clearSettledSteps(root: string): number {
213
231
  const steps = readPersonSteps(root)
@@ -48,7 +48,11 @@ export function slugify(name: string): string {
48
48
  }
49
49
 
50
50
  export function localProject(cwd: string): LocalProject {
51
- const root = findSolutionRoot(cwd)
51
+ return localProjectAt(findSolutionRoot(cwd))
52
+ }
53
+
54
+ /** The local project rooted at `root` itself, with no walk up. */
55
+ export function localProjectAt(root: string): LocalProject {
52
56
  const hash = createHash('sha256').update(root).digest('hex').slice(0, 8)
53
57
  return {
54
58
  root,
@@ -9,7 +9,9 @@ import {
9
9
  execInContainer,
10
10
  followLogs,
11
11
  imagePresent,
12
+ PullError,
12
13
  pullImage,
14
+ registryHost,
13
15
  removeContainer,
14
16
  runContainer,
15
17
  startContainer,
@@ -33,6 +35,7 @@ import {
33
35
  type LocalSecrets,
34
36
  } from './local-env'
35
37
  import { runtimeKeyCustodianProblem } from './local-key-custodian'
38
+ import { addPersonStep, markKeyedStepDone } from './local-person-steps'
36
39
  import { localEmbeddingsReachable, providerEnv } from './local-keys'
37
40
  import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
38
41
  import type { PortBlock } from './local-ports'
@@ -325,9 +328,44 @@ export async function runSupervisor(project: LocalProject, ports: PortBlock, ses
325
328
 
326
329
  type SetFn = (name: ComponentName, patch: Partial<ComponentState>) => void
327
330
 
328
- async function ensureImage(image: string, project: LocalProject, component: ComponentName): Promise<void> {
329
- if (imagePresent(image)) return
330
- await pullImage(image, logPath(project, component))
331
+ /**
332
+ * Every image the runtime runs comes through here. A pull refused for want of
333
+ * access is the person's step, recorded without the agent choosing to:
334
+ * one per image repository, so a retry finds the same step, and done once an
335
+ * image from that repository is on the machine.
336
+ */
337
+ export async function ensureImage(image: string, project: LocalProject, component: ComponentName): Promise<void> {
338
+ const repository = image.replace(/:[^/:]*$/, '')
339
+ const key = `image-access:${repository}`
340
+ if (!imagePresent(image)) {
341
+ try {
342
+ await pullImage(image, logPath(project, component))
343
+ } catch (err) {
344
+ if (!(err instanceof PullError) || err.failure !== 'no-access') throw err
345
+ const host = registryHost(image)
346
+ const ghcr = host === 'ghcr.io'
347
+ try {
348
+ addPersonStep(project.root, {
349
+ key,
350
+ source: 'runtime',
351
+ command: null,
352
+ credential: 'registry-owner',
353
+ changes: ghcr ? `Read access for your GitHub account to the organization's package ${repository}` : `Pull access for your account on ${host} to ${repository}`,
354
+ why: [
355
+ `Docker is signed in to ${host}, and the registry still refused ${image}, so the local runtime can't start from it.`,
356
+ ghcr ? "Ask an organization owner to grant the GitHub account Docker signed in with read access to the organization's packages." : `Ask an owner of ${host} to grant the account Docker signed in with pull access.`,
357
+ `If the account already has access, its sign-in no longer works: sign Docker in to ${host} again.`,
358
+ 'The next `frontera local start --image` that pulls the image marks this step done. Without access, run from a platform checkout without --image.',
359
+ ].join(' '),
360
+ })
361
+ } catch {
362
+ // Not recorded (200 steps already pending): the refusal still says what to do.
363
+ throw err
364
+ }
365
+ throw new PullError('no-access', `${err.message}. This is the person's step; \`frontera local steps\` lists it`)
366
+ }
367
+ }
368
+ markKeyedStepDone(project.root, key)
331
369
  }
332
370
 
333
371
  /**
@@ -578,6 +578,22 @@ export async function agentOperatingConfig(
578
578
  return { config, notes }
579
579
  }
580
580
 
581
+ /**
582
+ * The Knowledge app's bases an Agent searches are platform settings, not part
583
+ * of its files: a local runtime has no Knowledge app, and a base belongs to
584
+ * one installation. Said, so what the Agent answers locally is not taken for
585
+ * what it answers on the platform. Nothing to say where the platform has no
586
+ * Knowledge app, the Agent searches none, or the route is not there.
587
+ */
588
+ export async function knowledgeBasesNote(source: SyncSource, agentId: string): Promise<string | null> {
589
+ const answer = await source.workspace.get(`/v1/config/agents/${encodeURIComponent(agentId)}/knowledge-bases`).catch(() => null)
590
+ const bases = answer ? one<{ bases?: Array<{ name?: unknown; searched?: unknown }> } | null>(answer)?.bases ?? [] : []
591
+ const names = bases.filter((base) => base.searched === true && typeof base.name === 'string').map((base) => `“${base.name as string}”`)
592
+ if (!names.length) return null
593
+ const which = names.length === 1 ? `the knowledge base ${names[0]}` : `${names.length} knowledge bases (${names.join(', ')})`
594
+ return `Searches ${which} from the Knowledge app on the platform. A local runtime has no Knowledge app, so here it answers without them: test this Agent’s Knowledge on the platform`
595
+ }
596
+
581
597
  /** An Agent's record (`/v1/config/agents/:id`): its published version, and its behavior settings, scrubbed. */
582
598
  interface AgentDetail { currentVersion?: { versionNumber?: number } | null; operatingConfig?: unknown }
583
599
 
@@ -632,6 +648,8 @@ async function readAgents(source: SyncSource, lists: Lists, directory: Directory
632
648
  it.notes.push(`Its Context and data couldn’t be read on the platform: ${context.unread}. Until it can be read, local dev gives it read access to the object types the project holds, as an adaptation, so locally it may read more than on the platform`)
633
649
  }
634
650
  if (bound.functions.length || bound.workflows.length || bound.blueprint?.length || bound.blueprintUnread) docs.push({ path: `agents/${row.agentId}/bindings.json`, doc: bound, format: 'json' })
651
+ const knowledgeBases = await knowledgeBasesNote(source, row.id)
652
+ if (knowledgeBases) it.notes.push(knowledgeBases)
635
653
  await nameUnknownAgents(source, directory, docs)
636
654
  out.push(finish(it, directory, docs))
637
655
  }
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './e
9
9
  import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
10
10
  import { localRuntimeAt } from './local/local-runtime-target'
11
11
  import { acquireLock, lockRefusal, locksRuntime, projectStateDir, recordWrite, writeCommand } from './local/local-writers'
12
+ import { tokenFromStdin } from './mcp/mcp-apps'
12
13
  import { resolveOrganization, setSelectedOrganization } from './organization'
13
14
  import { canPrompt, promptConfirm } from './prompt'
14
15
  import { remoteFromLocalProject, remoteWords } from './remote-guard'
@@ -228,6 +229,10 @@ async function main(): Promise<number> {
228
229
  // project are always read from one place. Two directories would mean
229
230
  // `--dir ../other-customer` could edit one customer's app with another
230
231
  // customer's key.
232
+ //
233
+ // The `frontera app dev` that `--detach` starts takes its caller's key on stdin, used as FRONTERA_TOKEN
234
+ // would be, so it is never in the environment the App's own server inherits.
235
+ const piped = await tokenFromStdin()
231
236
  let credential = command.meta.offline
232
237
  ? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
233
238
  : await resolveCredential(
@@ -237,7 +242,7 @@ async function main(): Promise<number> {
237
242
  // In a local project, an organization command takes the runtime's organization key.
238
243
  organization: commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit',
239
244
  },
240
- { cwd },
245
+ { cwd, ...(piped ? { env: { ...process.env, FRONTERA_TOKEN: piped } } : {}) },
241
246
  )
242
247
  // A command that acts as a person takes the browser sign-in (`auth login --browser`) over the
243
248
  // profile's key, which it would refuse; nothing else does. A raw FRONTERA_TOKEN still wins.