@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
package/src/main.ts CHANGED
@@ -3,7 +3,9 @@ import { parseArgs } from './args'
3
3
  import { resolveCredential } from './config'
4
4
  import { findProjectRoot } from './context'
5
5
  import { CliError, UsageError } from './errors'
6
- import { EXIT, sessionLaneFailure, sessionLaneRefusal } from './exit'
6
+ import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './exit'
7
+ import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
8
+ import { localRuntimeAt } from './local/local-runtime-target'
7
9
  import { resolveOrganization, setSelectedOrganization } from './organization'
8
10
  import { createOutput, type OutputMode } from './output'
9
11
  import { readProject } from './project'
@@ -45,6 +47,20 @@ import type { AppProject } from './context'
45
47
  * on top — so it gets `-dev`, which is what stops a bug report from naming a
46
48
  * version whose code the reporter is not running.
47
49
  */
50
+ /**
51
+ * A key refused on a command that acts as a person, against the project's
52
+ * own local runtime: told to act as a local persona (`--as`), with the
53
+ * personas it has, never to fetch a person's session token. Null anywhere
54
+ * else, or for any other failure, to keep it as it is.
55
+ */
56
+ async function onLocalRuntime(failure: CliError, meta: { noun: string; verb: string; authLane?: string }, cwd: string, credential: { apiUrl: string; token: string }): Promise<CliError | null> {
57
+ if (meta.authLane !== 'session' || !credential.token.startsWith('sk-') || !credential.apiUrl) return null
58
+ const local = localRuntimeAt(cwd, credential.apiUrl)
59
+ if (!local) return null
60
+ const personas = await listPersonas(local.serviceUrl, local.creds)
61
+ return new CliError(failure.message, { code: failure.code, hint: localPersonaHint(meta, personas), cause: failure })
62
+ }
63
+
48
64
  async function resolveVersion(): Promise<string> {
49
65
  const stamped = process.env.FRONTERA_CLI_VERSION
50
66
  if (stamped) return stamped
@@ -134,7 +150,7 @@ async function main(): Promise<number> {
134
150
 
135
151
  const args = direct ? [maybeVerb, ...rest].filter((a): a is string => a !== undefined) : rest
136
152
  const spec = flagsFor(command.meta)
137
- const { positional, flags } = parseArgs(args, spec, aliasesFor(command.meta))
153
+ const { positional, flags, repeated } = parseArgs(args, spec, aliasesFor(command.meta))
138
154
 
139
155
  if (flags.help === true) {
140
156
  return emit(describeCommand(command.meta), () => renderCommandHelp(command.meta))
@@ -144,6 +160,10 @@ async function main(): Promise<number> {
144
160
  const output = createOutput(mode, { quiet: flags.quiet === true })
145
161
  // Read by the failure path: which credential was refused decides the hint.
146
162
  let token = ''
163
+ // Where that credential was sent: the project's own local runtime words a refusal differently.
164
+ let apiUrlUsed = ''
165
+ // A local persona's session (`--as`): redacted from anything a failure says.
166
+ let personaToken = ''
147
167
 
148
168
  try {
149
169
  // Reserved commands fail BEFORE anything else — no credential is
@@ -201,19 +221,37 @@ async function main(): Promise<number> {
201
221
  // project are always read from one place. Two directories would mean
202
222
  // `--dir ../other-customer` could edit one customer's app with another
203
223
  // customer's key.
204
- const credential = command.meta.offline
224
+ let credential = command.meta.offline
205
225
  ? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
206
226
  : await resolveCredential(
207
227
  {
208
228
  apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined,
209
229
  profile: typeof flags.profile === 'string' ? flags.profile : undefined,
230
+ // In a local project, an organization command takes the runtime's organization key.
231
+ organization: commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit',
210
232
  },
211
233
  { cwd },
212
234
  )
213
235
  token = credential.token
236
+ apiUrlUsed = credential.apiUrl
214
237
 
215
- const workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
216
- const orgFlag = typeof flags.org === 'string' ? flags.org.trim() : undefined
238
+ let workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
239
+ let orgFlag = typeof flags.org === 'string' ? flags.org.trim() : undefined
240
+
241
+ // `--as <persona>`: a local persona's session for this one command, on the project's
242
+ // own local runtime only (`local-personas.ts`). Its token is never printed.
243
+ if (typeof flags.as === 'string' && !command.meta.offline) {
244
+ const acting = await actAsLocalPersona({ meta: command.meta, cwd, apiUrl: credential.apiUrl, wanted: flags.as })
245
+ if ((workspaceFlag && workspaceFlag !== acting.workspaceId) || (orgFlag && orgFlag !== acting.organizationId)) {
246
+ throw new UsageError('--as acts in the local runtime\'s own workspace', 'drop --workspace and --org')
247
+ }
248
+ personaToken = acting.token
249
+ credential = { apiUrl: credential.apiUrl, token: acting.token }
250
+ token = acting.token
251
+ workspaceFlag = acting.workspaceId
252
+ orgFlag = acting.organizationId
253
+ output.note(`acting as ${acting.persona.displayName} (${acting.persona.key}), a local persona`)
254
+ }
217
255
 
218
256
  /**
219
257
  * `--workspace` is global — same reasoning as `--profile`: an escape hatch
@@ -258,7 +296,7 @@ async function main(): Promise<number> {
258
296
  ?? (missingArg
259
297
  ? null
260
298
  : unaddressedWorkspaceRefusal({ meta: command.meta, token: credential.token, workspace: workspaceFlag }))
261
- if (refusal) throw refusal
299
+ if (refusal) throw (await onLocalRuntime(refusal, command.meta, cwd, credential)) ?? refusal
262
300
  }
263
301
 
264
302
  /**
@@ -294,17 +332,20 @@ async function main(): Promise<number> {
294
332
  token: credential.token,
295
333
  positional,
296
334
  flags,
335
+ repeated,
297
336
  output,
298
337
  project,
299
338
  ...(workspaceFlag ? { workspaceId: workspaceFlag } : {}),
300
339
  })
301
340
 
302
- output.render(result.data, () => result.text)
303
- return EXIT.OK
341
+ output.render(personaToken ? redact(result.data, personaToken) : result.data, () => (personaToken ? redact(result.text, personaToken) : result.text))
342
+ return (result.exitCode ?? EXIT.OK) as ExitCode
304
343
  } catch (err) {
305
344
  // A key on a session-lane command: one recovery for every such noun,
306
345
  // rather than each API client rewording its own 401/403.
307
- return output.fail(sessionLaneFailure(command.meta, token, err) ?? err)
346
+ const laneFailure = sessionLaneFailure(command.meta, token, err)
347
+ const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, typeof flags.dir === 'string' ? flags.dir : process.cwd(), { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? err
348
+ return output.fail(personaToken ? redact(failure, personaToken) : failure)
308
349
  }
309
350
  }
310
351
 
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+
4
+ /**
5
+ * A basic accessibility scan of a rendered page (design 2026-10-18 §6):
6
+ * axe-core's WCAG 2 A and AA rules, run in the page `app_render` opened, in
7
+ * Forge's tab or headless. What it finds is kept with the render evidence,
8
+ * and a deployment's Accessibility check reads it: serious and critical
9
+ * findings fail it; the rest are listed.
10
+ */
11
+
12
+ export interface A11yFinding {
13
+ id: string
14
+ impact: 'minor' | 'moderate' | 'serious' | 'critical' | null
15
+ help: string
16
+ nodes: number
17
+ /** The first element it found, as a selector. */
18
+ target: string | null
19
+ }
20
+
21
+ export interface A11yResult {
22
+ /** Serious and critical findings: what fails the check. */
23
+ violations: number
24
+ findings: A11yFinding[]
25
+ }
26
+
27
+ let source: string | null | undefined
28
+
29
+ /** axe-core's script, read once; null when the CLI was installed without it. */
30
+ export function axeSource(): string | null {
31
+ if (source !== undefined) return source
32
+ try {
33
+ source = readFileSync(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8')
34
+ } catch {
35
+ source = null
36
+ }
37
+ return source
38
+ }
39
+
40
+ /** Run in the page after axe's script: the violations, in plain data. */
41
+ export const AXE_RUN = `(async () => {
42
+ const r = await axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] }, resultTypes: ['violations'] })
43
+ return r.violations.map((v) => ({ id: v.id, impact: v.impact || null, help: v.help, nodes: v.nodes.length, target: v.nodes[0] && v.nodes[0].target ? v.nodes[0].target.join(' ') : null }))
44
+ })()`
45
+
46
+ /** The scan's findings as the evidence keeps them: worst first, at most 20. */
47
+ export function summarizeA11y(raw: unknown): A11yResult | null {
48
+ if (!Array.isArray(raw)) return null
49
+ const rank = { critical: 0, serious: 1, moderate: 2, minor: 3 } as const
50
+ const findings = raw
51
+ .filter((f): f is A11yFinding => !!f && typeof (f as A11yFinding).id === 'string')
52
+ .map((f) => ({ id: f.id, impact: f.impact ?? null, help: String(f.help ?? '').slice(0, 200), nodes: Number(f.nodes) || 0, target: f.target ? String(f.target).slice(0, 200) : null }))
53
+ .sort((a, b) => (rank[a.impact ?? 'minor'] ?? 4) - (rank[b.impact ?? 'minor'] ?? 4))
54
+ return { violations: findings.filter((f) => f.impact === 'serious' || f.impact === 'critical').length, findings: findings.slice(0, 20) }
55
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A page's accessibility tree as the coding agent reads it, in the shape
3
+ * Playwright MCP's snapshot uses: one line per meaningful node, indented by
4
+ * depth, with a ref for each element the agent can click or type into.
5
+ * Built from Chrome's `Accessibility.getFullAXTree`.
6
+ *
7
+ * Shared by both browsers the coding agent drives: Forge's (which re-exports
8
+ * this module) and the headless one `frontera mcp` runs without Forge, so a
9
+ * snapshot and its refs read the same in either.
10
+ */
11
+
12
+ export interface AxValue {
13
+ type?: string
14
+ value?: unknown
15
+ }
16
+
17
+ export interface AxNode {
18
+ nodeId: string
19
+ ignored?: boolean
20
+ role?: AxValue
21
+ name?: AxValue
22
+ value?: AxValue
23
+ description?: AxValue
24
+ properties?: Array<{ name: string; value: AxValue }>
25
+ childIds?: string[]
26
+ parentId?: string
27
+ backendDOMNodeId?: number
28
+ }
29
+
30
+ /** Roles that only group: their children are shown in their place. */
31
+ const TRANSPARENT = new Set(['generic', 'none', 'presentation', 'InlineTextBox', 'LineBreak', 'RootWebArea', 'WebArea', 'Section', 'group'])
32
+
33
+ /** Roles an agent acts on; they get a ref. */
34
+ const ACTIONABLE = new Set([
35
+ 'button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton',
36
+ 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'tab', 'treeitem', 'listbox', 'gridcell', 'row', 'columnheader',
37
+ 'textarea', 'select', 'summary', 'disclosure triangle',
38
+ ])
39
+
40
+ const SHOWN_PROPERTIES = ['level', 'checked', 'pressed', 'expanded', 'selected', 'disabled', 'required', 'invalid', 'readonly'] as const
41
+
42
+ const MAX_LINES = 1_200
43
+
44
+ function text(v: AxValue | undefined): string {
45
+ if (v?.value === undefined || v.value === null) return ''
46
+ return String(v.value).replace(/\s+/g, ' ').trim()
47
+ }
48
+
49
+ /**
50
+ * A frame's tree read into the page's (an App embedded in Studio): shown under
51
+ * its <iframe> element, keyed there by that element's accessibility node id.
52
+ */
53
+ export interface FrameContent {
54
+ nodes: AxNode[]
55
+ /** The frame's address, without its query. */
56
+ url: string
57
+ /** The number an element of this frame is named by (its handle), from its DOM node. */
58
+ handleOf: (backendDOMNodeId: number) => number
59
+ frames?: Map<string, FrameContent | { error: string }>
60
+ }
61
+
62
+ export interface Snapshot {
63
+ /** The tree as text. */
64
+ text: string
65
+ /** Each ref's DOM node (a handle, for an element in a frame), for the actions. */
66
+ refs: Map<string, number>
67
+ /** Each ref's role and name, to find the element again after the page changes. */
68
+ labels: Map<string, { role: string; name: string }>
69
+ truncated: boolean
70
+ }
71
+
72
+ /**
73
+ * `refFor` keeps refs stable: the tab hands out one ref per DOM node for as
74
+ * long as the document lives, so e1 stays the same element across snapshots
75
+ * and actions until the page navigates. Without it, refs count from e1.
76
+ */
77
+ export function formatSnapshot(
78
+ nodes: AxNode[],
79
+ opts: { url: string; title: string; refPrefix?: string; refFor?: (backendDOMNodeId: number) => string; frames?: Map<string, FrameContent | { error: string }> },
80
+ ): Snapshot {
81
+ const refs = new Map<string, number>()
82
+ const labels = new Map<string, { role: string; name: string }>()
83
+ const lines: string[] = [`- Page URL: ${opts.url}`, `- Page title: ${opts.title}`, '- Snapshot:']
84
+ let counter = 0
85
+ let truncated = false
86
+
87
+ interface Tree {
88
+ byId: Map<string, AxNode>
89
+ handleOf: (backendDOMNodeId: number) => number
90
+ frames: Map<string, FrameContent | { error: string }> | undefined
91
+ }
92
+ const treeOf = (ns: AxNode[], handleOf: Tree['handleOf'], frames: Tree['frames']): { tree: Tree; root: AxNode | undefined } => {
93
+ const byId = new Map(ns.map((n) => [n.nodeId, n]))
94
+ return { tree: { byId, handleOf, frames }, root: ns.find((n) => !n.parentId || !byId.has(n.parentId)) ?? ns[0] }
95
+ }
96
+
97
+ const visit = (tree: Tree, node: AxNode, depth: number, parentName: string) => {
98
+ if (lines.length >= MAX_LINES) {
99
+ truncated = true
100
+ return
101
+ }
102
+ const role = text(node.role)
103
+ const name = text(node.name)
104
+ const kids = (node.childIds ?? []).map((id) => tree.byId.get(id)).filter((n): n is AxNode => !!n)
105
+ // Text that only repeats its parent's name adds nothing.
106
+ if (role === 'StaticText') {
107
+ if (name && name !== parentName) lines.push(`${' '.repeat(depth)}- text: ${JSON.stringify(name)}`)
108
+ return
109
+ }
110
+ if (node.ignored || TRANSPARENT.has(role) || !role) {
111
+ for (const k of kids) visit(tree, k, depth, parentName)
112
+ return
113
+ }
114
+ const props = (node.properties ?? [])
115
+ .filter((p) => (SHOWN_PROPERTIES as readonly string[]).includes(p.name) && p.value.value !== false && p.value.value !== 'false' && p.value.value !== undefined)
116
+ .map((p) => (p.value.value === true || p.value.value === 'true' ? p.name : `${p.name}=${String(p.value.value)}`))
117
+ let ref = ''
118
+ if (node.backendDOMNodeId !== undefined && (ACTIONABLE.has(role) || props.includes('focusable'))) {
119
+ const handle = tree.handleOf(node.backendDOMNodeId)
120
+ const id = opts.refFor ? opts.refFor(handle) : `${opts.refPrefix ?? 'e'}${++counter}`
121
+ refs.set(id, handle)
122
+ labels.set(id, { role, name })
123
+ ref = id
124
+ }
125
+ const value = text(node.value)
126
+ const frame = tree.frames?.get(node.nodeId)
127
+ const bits = [`- ${role}`, name ? JSON.stringify(name) : '', ...props.map((p) => `[${p}]`), ref ? `[ref=${ref}]` : '', frame && 'url' in frame ? `[frame=${frame.url}]` : ''].filter(Boolean).join(' ')
128
+ lines.push(`${' '.repeat(depth)}${bits}${value && value !== name ? `: ${JSON.stringify(value.slice(0, 200))}` : ''}`)
129
+ for (const k of kids) visit(tree, k, depth + 1, name)
130
+ // An embedded frame (an App in Studio): its page, under its <iframe>, with refs that act inside it.
131
+ if (frame && 'error' in frame) lines.push(`${' '.repeat(depth + 1)}- text: ${JSON.stringify(`(the frame could not be read: ${frame.error})`)}`)
132
+ else if (frame) {
133
+ const inner = treeOf(frame.nodes, frame.handleOf, frame.frames)
134
+ if (inner.root) visit(inner.tree, inner.root, depth + 1, name)
135
+ }
136
+ }
137
+
138
+ const top = treeOf(nodes, (n) => n, opts.frames)
139
+ if (top.root) visit(top.tree, top.root, 0, '')
140
+ if (truncated) lines.push(`- … the page has more; the snapshot stops at ${MAX_LINES} lines`)
141
+ return { text: lines.join('\n'), refs, labels, truncated }
142
+ }
143
+
144
+ /** After the page changed, the ref in a new snapshot for the same element: same role and name, the first one. */
145
+ export function findAgain(label: { role: string; name: string }, snapshot: Snapshot): string | null {
146
+ for (const [ref, l] of snapshot.labels) if (l.role === label.role && l.name === label.name) return ref
147
+ return null
148
+ }
149
+
150
+ /**
151
+ * One document's refs: a DOM node keeps the ref it first got for as long as
152
+ * the document lives, so a ref from any snapshot of the page still names the
153
+ * same element after the agent's actions. Cleared when the tab navigates to
154
+ * another document (the nodes are gone with it).
155
+ */
156
+ export class RefBook {
157
+ private byNode = new Map<number, string>()
158
+ private byRef = new Map<string, number>()
159
+ private counter = 0
160
+
161
+ constructor(private readonly prefix = 'e') {}
162
+
163
+ readonly refFor = (backendDOMNodeId: number): string => {
164
+ const known = this.byNode.get(backendDOMNodeId)
165
+ if (known) return known
166
+ const ref = `${this.prefix}${++this.counter}`
167
+ this.byNode.set(backendDOMNodeId, ref)
168
+ this.byRef.set(ref, backendDOMNodeId)
169
+ return ref
170
+ }
171
+
172
+ node(ref: string): number | undefined {
173
+ return this.byRef.get(ref)
174
+ }
175
+
176
+ reset(): void {
177
+ this.byNode.clear()
178
+ this.byRef.clear()
179
+ this.counter = 0
180
+ }
181
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Credentials that ride in a local address, which the coding agent's browser
3
+ * keeps out of everything it reports (tab addresses, the network list, the
4
+ * console, snapshots, script results, render observations), in Forge's
5
+ * Browser and the headless one alike:
6
+ *
7
+ * - Studio's sign-in link, `?token=`: swapped for a cookie, then redirected away from.
8
+ * - An App's launch token, `?t=` on the App's frame address: a signed asset
9
+ * token (its app, user and version, then a signature) that loads that App's
10
+ * build as that person until it expires. The App's first page hands it to
11
+ * its worker and drops it from the address, but the first request carries it.
12
+ * - The usual names an address carries a credential under (`access_token`,
13
+ * `code`, `session`, `handoff`…), in the query or the fragment.
14
+ *
15
+ * Only on this machine's addresses: a site elsewhere is the person's own
16
+ * business, and its parameters are not this project's credentials.
17
+ */
18
+
19
+ /** What a credential is shown as instead. */
20
+ export const STUDIO_SESSION = '[studio-session]'
21
+ export const APP_LAUNCH_TOKEN = '[app-launch-token]'
22
+ export const ADDRESS_CREDENTIAL = '[credential]'
23
+
24
+ const NAMED = ['access_token', 'id_token', 'refresh_token', 'code', 'session', 'session_token', 'handoff', 'auth', 'key', 'api_key'] as const
25
+
26
+ /** A signed token's shape (`<base64url body>.<base64url signature>`), so `t` as a cache-busting timestamp (Vite's HMR) is not taken for one. */
27
+ const SIGNED = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}$/
28
+
29
+ /** A loopback address: where the project's own pages live. */
30
+ function local(url: URL): boolean {
31
+ const h = url.hostname
32
+ return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost')
33
+ }
34
+
35
+ /** The credentials an address carries, each with what it is shown as instead; none for an address elsewhere. */
36
+ export function credentialsIn(address: string): Array<{ secret: string; label: string }> {
37
+ let url: URL
38
+ try {
39
+ url = new URL(address)
40
+ } catch {
41
+ return []
42
+ }
43
+ if (!local(url)) return []
44
+ const out: Array<{ secret: string; label: string }> = []
45
+ const lists = [url.searchParams]
46
+ if (url.hash.length > 1) lists.push(new URLSearchParams(url.hash.slice(1)))
47
+ for (const params of lists) {
48
+ const token = params.get('token')
49
+ if (token) out.push({ secret: token, label: STUDIO_SESSION })
50
+ const launch = params.get('t')
51
+ if (launch && SIGNED.test(launch)) out.push({ secret: launch, label: APP_LAUNCH_TOKEN })
52
+ for (const name of NAMED) {
53
+ const value = params.get(name)
54
+ if (value) out.push({ secret: value, label: ADDRESS_CREDENTIAL })
55
+ }
56
+ }
57
+ // Too short to be a secret, and too likely to match ordinary words.
58
+ return out.filter((c) => c.secret.length >= 8)
59
+ }
60
+
61
+ /** Text with each secret blanked out, as typed and as an address encodes it. */
62
+ export function redactWith(text: string, secrets: ReadonlyMap<string, string>): string {
63
+ let out = text
64
+ for (const [secret, label] of secrets) out = out.split(secret).join(label).split(encodeURIComponent(secret)).join(label)
65
+ return out
66
+ }
67
+
68
+ /** Every string in a value with the secrets blanked out. */
69
+ export function redactDeep<T>(value: T, secrets: ReadonlyMap<string, string>): T {
70
+ if (!secrets.size) return value
71
+ const walk = (v: unknown): unknown => {
72
+ if (typeof v === 'string') return redactWith(v, secrets)
73
+ if (Array.isArray(v)) return v.map(walk)
74
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x)]))
75
+ return v
76
+ }
77
+ return walk(value) as T
78
+ }