Show Orca publishing guidance only when the host has it on (STA-12225) (#34547)

This commit is contained in:
Jinwoo Hong
2026-10-27 18:20:40 -04:00
committed by GitHub
parent 9cc555e2de
commit 4e326e2371
17 changed files with 456 additions and 2402 deletions
@@ -124,31 +124,10 @@ function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function fullConstantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_FULL_MARKDOWN`
}
function referenceConstantName(guideName, referenceName) {
return `${`${guideName}_${referenceName}`.replace(/-/g, '_').toUpperCase()}_REFERENCE_MARKDOWN`
}
function composeFullMarkdown(markdown, references) {
if (references.length === 0) {
return markdown
}
const packageHeader =
'\n\n---\n\n# Bundled references\n\n' +
'These references belong to the version-matched guide above. Read only the documents ' +
'named by its action gates.\n'
const documents = references
.map(
({ relativePath, markdown: referenceMarkdown }) =>
`\n<!-- bundled-reference: ${relativePath} -->\n\n${referenceMarkdown.trimEnd()}\n`
)
.join('')
return `${markdown.trimEnd()}${packageHeader}${documents}`
}
function serializeMarkdown(markdown) {
const lines = markdown.split('\n').map((line) => ` ${JSON.stringify(line)},`)
return `[\n${lines.join('\n')}\n].join('\\n')`
@@ -167,11 +146,6 @@ function serializeEmbeddedModule(guides) {
const constants = [
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${serializeMarkdown(guide.markdown)}`
]
if (guide.fullMarkdown !== guide.markdown) {
constants.push(
`// oxfmt-ignore\nconst ${fullConstantName(guide.name)} = ${serializeMarkdown(guide.fullMarkdown)}`
)
}
for (const reference of guide.references) {
constants.push(
`// oxfmt-ignore\nconst ${referenceConstantName(guide.name, reference.name)} = ${serializeMarkdown(reference.markdown)}`
@@ -194,7 +168,6 @@ function serializeEmbeddedModule(guides) {
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${guide.fullMarkdown === guide.markdown ? markdownConstant : fullConstantName(guide.name)},`,
` aliases: ${JSON.stringify(guide.aliases)},`,
` references: [${referenceEntries}]`,
' }'
@@ -202,7 +175,7 @@ function serializeEmbeddedModule(guides) {
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
async function readGuideReferences(repoRoot, guideName) {
@@ -231,7 +204,7 @@ async function readGuideReferences(repoRoot, guideName) {
if (!markdown.trim()) {
throw new Error(`Guide reference is empty: ${toPosixRelativePath(repoRoot, sourcePath)}`)
}
return { name: entry.name.slice(0, -3), relativePath: `references/${entry.name}`, markdown }
return { name: entry.name.slice(0, -3), markdown }
})
)
}
@@ -319,13 +292,8 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
name,
description: frontmatter.description,
markdown,
fullMarkdown: composeFullMarkdown(markdown, references),
aliases,
// Per-reference requests need individual identity erased by fullMarkdown concatenation.
references: references.map(({ name: referenceName, markdown: referenceMarkdown }) => ({
name: referenceName,
markdown: referenceMarkdown
}))
references
})
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
const content = stubTopics.has(name)
@@ -398,7 +366,6 @@ export {
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
composeFullMarkdown,
composeStubProjection,
frontmatterBlock,
normalizeMarkdown,
@@ -35,7 +35,7 @@ const GUIDE_REFERENCES = {
'recovery-and-cleanup.md',
'worker-contract.md'
],
'orca-cli': ['automations.md', 'browser.md', 'publishing.md'],
'orca-cli': ['artifacts.md', 'automations.md', 'browser.md', 'skill-sharing.md'],
'orca-per-workspace-env': [
'docker-ssh.md',
'failure-modes.md',
@@ -191,7 +191,6 @@ describe('bundled skill guide generator', () => {
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
const references = GUIDE_REFERENCES[guide.name]
if (!references) {
expect(guide.fullMarkdown).toBe(source)
expect(guide.references).toEqual([])
continue
}
@@ -215,21 +214,6 @@ describe('bundled skill guide generator', () => {
)
)
}
expect(guide.fullMarkdown).not.toBe(guide.markdown)
expect(guide.fullMarkdown.length).toBeGreaterThan(guide.markdown.length)
expect(guide.fullMarkdown.startsWith(source.trimEnd())).toBe(true)
for (const reference of references) {
const marker = `<!-- bundled-reference: references/${reference} -->`
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
expect(guide.fullMarkdown).toContain(
normalizeMarkdown(
await readFile(
path.join(projectDir, 'skill-guides', guide.name, 'references', reference),
'utf8'
)
)
)
}
}
})
+5 -5
View File
@@ -52,16 +52,16 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535",
"packageDigest": "b461950523a5d2a7be9c35d9029190204cf4c5f082d49edb55090dda234e924e",
"files": [
{
"path": "SKILL.md",
"size": 2372,
"size": 2312,
"executable": false,
"classification": "text",
"exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
"textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
"identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a"
"exactSha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
"textNormalizedSha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
"identitySha256": "e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3"
}
]
},
+1
View File
@@ -70,6 +70,7 @@
"b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
"baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56",
"cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"e9759aea664f54561946eeb40a6328031330860cda0817f2c2aa2cf529d5c1b3",
"ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4",
"ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137"
+37 -23
View File
@@ -1,13 +1,13 @@
---
name: orca-cli
description: >-
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, artifacts,
skill sharing, worktree comments, and Orca's embedded browser through the `orca` CLI. Use
when the user says "$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a
worktree", "read/wait/send Orca terminal", "handoff" / "handover" / "give this to another
agent", "Orca browser", "orca artifacts", or "share skills". Prefer it over raw git
worktree, ad hoc PTYs, or Computer Use when Orca state is involved. Use Computer Use only
when a visible window needs GUI control that a CLI, filesystem, or API cannot do.
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree
comments, and Orca's embedded browser through the `orca` CLI. Use when the user says
"$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "handoff" / "handover" / "give this to another agent", or
"Orca browser". Prefer it over raw git worktree, ad hoc PTYs, or Computer Use when Orca
state is involved. Use Computer Use only when a visible window needs GUI control that a
CLI, filesystem, or API cannot do.
---
# Orca CLI
@@ -239,26 +239,41 @@ Terminal rules:
- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.
- `--direction vertical` splits left/right. `--direction horizontal` splits top/bottom.
<!-- host-capability: artifact-publishing -->
## Artifacts
Artifacts publish HTML or Markdown files through the signed-in Orca account. Anyone can view
the share URL; creating, listing, updating, and deleting need the active profile signed in.
Artifacts publish HTML or Markdown files as public links through the signed-in Orca account.
Use them only when the user explicitly asks to publish through Orca. A generic request for a
public link, page, or URL does not select Orca, and a provider the user names always wins.
**Publishing is off by default and only a human can turn it on.** `share` and `update` need a
device-wide capability the user grants in the desktop app under Settings → Artifacts ("Allow
publishing public artifact links"). It applies to every caller on the device, agent or human.
There is no CLI or RPC way to grant it. `list`, `unshare`, and `delete` are never gated, so old
links stay auditable and revocable.
**Only a human can turn publishing on or off.** `share` and `update` need the device-wide
setting under Settings → Artifacts ("Allow publishing public artifact links"); there is no
CLI or RPC way to grant it. `list`, `unshare`, and `delete` are never gated, so old links
stay auditable and revocable.
A denied share fails with `artifact_sharing_disabled` before any upload. Do not retry; the
answer will not change until a human acts. Tell the user to turn the setting on and re-run, or
deliver the file locally if they decline.
A denied share fails with `artifact_sharing_disabled` before any upload. Do not retry. Tell
the user publishing is off; mention the setting only if they asked for Orca by name, and
otherwise deliver the file locally or through the provider they chose.
On a headless Orca server, artifact and skill-sharing commands fail with
`sharing_unavailable_on_host`: no Orca cloud sign-in exists there. Do not retry or ask the
user to change settings; tell them to share from the Orca desktop app instead.
On a headless Orca server, artifact commands fail with `sharing_unavailable_on_host`: no
Orca cloud sign-in exists there. Do not retry or ask the user to change settings; tell them
to share from the Orca desktop app instead.
The `artifacts` commands, and the separate default-off permission for publishing installed skills, are in `references/publishing.md`. Load it before publishing either kind of link; a skill folder can hold scripts, configuration, or credentials.
The `artifacts` commands are in `references/artifacts.md`. Load it before publishing.
<!-- /host-capability -->
<!-- host-capability: skill-publishing -->
## Skill Sharing
Publish installed skills only when the user explicitly asks to share skills through Orca,
and only the skills they named: a skill folder can hold scripts, configuration, or
credentials. The commands and their permission are in `references/skill-sharing.md`. Load
it before publishing.
<!-- /host-capability -->
## Built-In Browser
@@ -298,11 +313,10 @@ Search rules:
## Conditional references
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference from the same CLI build, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference this CLI build serves on this host, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
| Action gate | Reference |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
@@ -0,0 +1,32 @@
<!-- host-capability: artifact-publishing -->
# Artifact commands
The publish gate, explicit-intent rule, and denial recovery are in the guide body.
```text
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json
ORCA artifacts unshare <file> --json
ORCA artifacts list [--cursor <cursor>] --json
ORCA artifacts delete <id> --json
```
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
- `share` saves the returned edit token in the active Orca profile and never includes it
in CLI output. `update` and `unshare` look up that record by the resolved local file
path, so use the same path and Orca profile that originally shared the file.
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
artifact by the id returned from `list`; it does not need the original local file or its
edit-token record.
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
asset URLs.
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
by the error.
- For local or staging development, `--api-url <url>` overrides the artifact service;
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
<!-- /host-capability -->
@@ -1,35 +1,6 @@
# Artifact and skill publishing commands
<!-- host-capability: skill-publishing -->
The publish gate and its recovery are in the guide body. This is the command surface behind it.
## Artifacts
```text
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json
ORCA artifacts unshare <file> --json
ORCA artifacts list [--cursor <cursor>] --json
ORCA artifacts delete <id> --json
```
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
- `share` saves the returned edit token in the active Orca profile and never includes it
in CLI output. `update` and `unshare` look up that record by the resolved local file
path, so use the same path and Orca profile that originally shared the file.
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
artifact by the id returned from `list`; it does not need the original local file or its
edit-token record.
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
asset URLs.
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
by the error.
- For local or staging development, `--api-url <url>` overrides the artifact service;
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
## Skill sharing
# Skill sharing commands
Agents can publish one or more installed skills behind one unlisted link through the
signed-in Orca account. The user must first grant the separate, default-off permission in
@@ -51,8 +22,8 @@ ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <nam
intentionally unsupported; name every skill the user asked to publish.
- Skill folders can contain scripts, configuration, or credentials. The permission is
authority, not intent: publish only the skills the user named and never widen the set.
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
enable the switch in the desktop app if they want this action.
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; the user turned
the permission off. Mention the switch only if they asked to share skills through Orca.
- On a headless Orca server, skill and artifact sharing fail with `sharing_unavailable_on_host`.
Do not retry; the host has no Orca cloud sign-in, so the user must share from the desktop app.
- Orca stages one agent-published bundle at a time per host. If another publish is active,
@@ -65,3 +36,5 @@ ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <nam
wrong filesystem.
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
includes cloud authentication tokens.
<!-- /host-capability -->
+7 -7
View File
@@ -1,13 +1,13 @@
---
name: orca-cli
description: >-
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, artifacts,
skill sharing, worktree comments, and Orca's embedded browser through the `orca` CLI. Use
when the user says "$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a
worktree", "read/wait/send Orca terminal", "handoff" / "handover" / "give this to another
agent", "Orca browser", "orca artifacts", or "share skills". Prefer it over raw git
worktree, ad hoc PTYs, or Computer Use when Orca state is involved. Use Computer Use only
when a visible window needs GUI control that a CLI, filesystem, or API cannot do.
Operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree
comments, and Orca's embedded browser through the `orca` CLI. Use when the user says
"$orca-cli", "Orca worktree", "child worktree", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "handoff" / "handover" / "give this to another agent", or
"Orca browser". Prefer it over raw git worktree, ad hoc PTYs, or Computer Use when Orca
state is involved. Use Computer Use only when a visible window needs GUI control that a
CLI, filesystem, or API cannot do.
---
# Orca CLI
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,6 @@ export type BundledSkillGuide = {
name: string
description: string
markdown: string
fullMarkdown: string
aliases: readonly string[]
references: readonly BundledSkillGuideReference[]
}
+81 -10
View File
@@ -1,4 +1,4 @@
import type { CommandHandler } from '../dispatch'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { writeStdoutLine } from '../stdout-line'
import {
@@ -7,8 +7,26 @@ import {
type BundledSkillGuide,
type BundledSkillGuideReference
} from './bundled-skill-guide-table'
import {
filterHostCapabilityBlocks,
hasHostCapabilityBlocks,
readEnabledHostCapabilities
} from './skill-guide-host-gate'
type GuideSelection = { full: boolean; reference: string | null; listReferences: boolean }
type HostGate = (markdown: string) => Promise<string>
// Query the host at most once, and only when the served text has host-gated blocks.
function createHostGate(ctx: HandlerContext): HostGate {
let enabled: ReturnType<typeof readEnabledHostCapabilities> | undefined
return async (markdown) => {
if (!hasHostCapabilityBlocks(markdown)) {
return markdown
}
enabled ??= readEnabledHostCapabilities(ctx)
return filterHostCapabilityBlocks(markdown, await enabled)
}
}
// Accept references/<file>.md exactly as published in the kernel's gate table.
function normalizeReferenceSelector(value: string): string {
@@ -55,28 +73,77 @@ function requireReferences(guide: BundledSkillGuide): readonly BundledSkillGuide
return guide.references
}
function requireReference(guide: BundledSkillGuide, requested: string): BundledSkillGuideReference {
const references = requireReferences(guide)
// A reference whose blocks are all withheld is not served at all.
async function servedReferences(
guide: BundledSkillGuide,
gate: HostGate
): Promise<BundledSkillGuideReference[]> {
const references = await Promise.all(
requireReferences(guide).map(async ({ name, markdown }) => ({
name,
markdown: await gate(markdown)
}))
)
return references.filter((reference) => reference.markdown.trim().length > 0)
}
async function requireReference(
guide: BundledSkillGuide,
requested: string,
gate: HostGate
): Promise<BundledSkillGuideReference> {
const selector = normalizeReferenceSelector(requested)
const match = references.find((reference) => reference.name === selector)
const match = requireReferences(guide).find((reference) => reference.name === selector)
if (!match) {
const available = references.map((reference) => reference.name).join(', ')
const available = (await servedReferences(guide, gate))
.map((reference) => reference.name)
.join(', ')
throw new RuntimeClientError(
'invalid_argument',
`Unknown reference "${requested}" for ${guide.name}. Available: ${available}`
)
}
return match
const markdown = await gate(match.markdown)
if (markdown.trim().length === 0) {
throw new RuntimeClientError(
'invalid_argument',
`Reference "${requested}" for ${guide.name} is unavailable: the Orca feature it covers is turned off on this host.`
)
}
return { name: match.name, markdown }
}
// Composed at serve time so withheld references leave no trace in --full.
function composeFullGuide(
markdown: string,
references: readonly BundledSkillGuideReference[]
): string {
if (references.length === 0) {
return markdown
}
const header =
'\n\n---\n\n# Bundled references\n\n' +
'These references belong to the version-matched guide above. Read only the documents ' +
'named by its action gates.\n'
const documents = references
.map(
(reference) =>
`\n<!-- bundled-reference: references/${reference.name}.md -->\n\n${reference.markdown.trimEnd()}\n`
)
.join('')
return `${markdown.trimEnd()}${header}${documents}`
}
export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
'skills get': async ({ flags, json }) => {
'skills get': async (ctx) => {
const { flags, json } = ctx
const selection = resolveSelection(flags)
const guides = await loadCanonicalGuides()
const guide = requireTopic(flags, guides)
const gate = createHostGate(ctx)
if (selection.listReferences) {
const names = requireReferences(guide).map((reference) => reference.name)
const names = (await servedReferences(guide, gate)).map((reference) => reference.name)
writeStdoutLine(
json ? JSON.stringify({ name: guide.name, references: names }, null, 2) : names.join('\n')
)
@@ -84,7 +151,7 @@ export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
}
if (selection.reference !== null) {
const reference = requireReference(guide, selection.reference)
const reference = await requireReference(guide, selection.reference, gate)
writeStdoutLine(
json
? JSON.stringify(
@@ -97,7 +164,11 @@ export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
return
}
const markdown = selection.full ? guide.fullMarkdown : guide.markdown
const kernel = await gate(guide.markdown)
const markdown =
selection.full && guide.references.length > 0
? composeFullGuide(kernel, await servedReferences(guide, gate))
: kernel
writeStdoutLine(
json
? JSON.stringify({ name: guide.name, full: selection.full, markdown }, null, 2)
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type Settings = { artifactSharingEnabled?: boolean; agentSkillSharingEnabled?: boolean }
type Host = { settings: Settings | Error; isRemote?: boolean } | 'no-access'
const { hostState } = vi.hoisted(() => {
const state: { host: Host; calls: number } = { host: 'no-access', calls: 0 }
return { hostState: state }
})
vi.mock('../runtime-client', async () => {
// Error formatting uses instanceof, so the mock must preserve the production classes.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
class RuntimeClient {
readonly isRemote: boolean
constructor() {
if (hostState.host === 'no-access') {
throw new Error('skills get contacted the host')
}
this.isRemote = hostState.host.isRemote ?? false
}
async call(method: string): Promise<{ result: { settings: Settings } }> {
hostState.calls += 1
if (hostState.host === 'no-access' || method !== 'settings.get') {
throw new Error(`unexpected ${method}`)
}
if (hostState.host.settings instanceof Error) {
throw hostState.host.settings
}
return { result: { settings: hostState.host.settings } }
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
serveOrcaApp: vi.fn(),
getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data')
}
})
import { BUNDLED_SKILL_GUIDES } from '../bundled-skill-guides'
import { main } from '../index'
import { GUIDE_HOST_CAPABILITIES } from './skill-guide-host-gate'
const PUBLISHING_TEXT = /artifacts share|skills share|## Artifacts|## Skill Sharing/
async function skillsGet(args: string[], host: Host): Promise<{ stdout: string; stderr: string }> {
hostState.host = host
let stdout = ''
let stderr = ''
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout += String(chunk)
return true
})
vi.spyOn(console, 'error').mockImplementation((message) => {
stderr += String(message)
})
await main(['skills', 'get', 'orca-cli', ...args], '/tmp/repo')
vi.restoreAllMocks()
return { stdout, stderr }
}
describe('skills get host-capability gating', () => {
beforeEach(() => {
hostState.calls = 0
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
process.exitCode = undefined
})
it('only ships closed, unnested blocks that name a known capability', () => {
const documents = BUNDLED_SKILL_GUIDES.flatMap((guide) => [
guide.markdown,
...guide.references.map((reference) => reference.markdown)
])
for (const markdown of documents) {
let open: string | null = null
for (const [, closing, name] of markdown.matchAll(
/<!-- (\/?)host-capability(?:: ([a-z-]+))? -->/g
)) {
if (closing) {
expect(open).not.toBeNull()
open = null
continue
}
expect(open).toBeNull()
expect(GUIDE_HOST_CAPABILITIES).toContain(name)
open = name
}
expect(open).toBeNull()
}
})
it.each<[string, Host]>([
['publishing is off', { settings: { artifactSharingEnabled: false } }],
['the host omits the settings (headless or older)', { settings: {} }],
['the host is unreachable', { settings: new Error('Orca is not running') }]
])('withholds publishing from every guide shape when %s', async (_label, host) => {
const guide = await skillsGet([], host)
const full = await skillsGet(['--full'], host)
const listed = await skillsGet(['--references'], host)
const withheld = await skillsGet(['--reference', 'references/artifacts.md'], host)
expect(guide.stdout).not.toMatch(PUBLISHING_TEXT)
expect(full.stdout).not.toMatch(PUBLISHING_TEXT)
expect(full.stdout).not.toContain('host-capability')
expect(listed.stdout.trim().split('\n')).toEqual(['automations', 'browser'])
expect(withheld.stdout).toBe('')
expect(withheld.stderr).toContain('turned off on this host')
})
it('serves publishing guidance when the host has it on, asking the host once', async () => {
const host: Host = {
settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true }
}
const full = await skillsGet(['--full'], host)
expect(hostState.calls).toBe(1)
const listed = await skillsGet(['--references'], host)
expect(full.stdout).toContain('## Artifacts')
expect(full.stdout).toContain('ORCA artifacts share <file> --json')
expect(full.stdout).toContain('ORCA skills share --skill')
expect(full.stdout).not.toContain('host-capability')
expect(listed.stdout.trim().split('\n')).toEqual([
'artifacts',
'automations',
'browser',
'skill-sharing'
])
})
it.each<[string, Host, Record<string, string>]>([
[
'a remote runtime',
{
settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true },
isRemote: true
},
{}
],
[
'a forwarded invocation',
{ settings: { artifactSharingEnabled: true, agentSkillSharingEnabled: true } },
{ ORCA_CLI_CWD: '/remote/repo' }
]
])('withholds skill sharing from %s, where skills share refuses', async (_label, host, env) => {
for (const [name, value] of Object.entries(env)) {
vi.stubEnv(name, value)
}
const guide = await skillsGet([], host)
expect(guide.stdout).toContain('## Artifacts')
expect(guide.stdout).not.toContain('## Skill Sharing')
})
it('serves an ungated reference without contacting the host', async () => {
const { stdout, stderr } = await skillsGet(['--reference', 'browser'], 'no-access')
expect(stderr).toBe('')
expect(stdout.length).toBeGreaterThan(0)
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { HandlerContext } from '../dispatch'
// Guides wrap feature-specific text in these blocks so agents only learn features the host has on.
export const GUIDE_HOST_CAPABILITIES = ['artifact-publishing', 'skill-publishing'] as const
export type GuideHostCapability = (typeof GUIDE_HOST_CAPABILITIES)[number]
const HOST_CAPABILITY_BLOCK =
/<!-- host-capability: ([a-z-]+) -->\n+([\s\S]*?)\n+<!-- \/host-capability -->(\n*)/g
export function hasHostCapabilityBlocks(markdown: string): boolean {
return markdown.includes('<!-- host-capability: ')
}
export function filterHostCapabilityBlocks(markdown: string, enabled: ReadonlySet<string>): string {
return markdown.replace(HOST_CAPABILITY_BLOCK, (_block, name: string, body: string, tail) =>
enabled.has(name) ? `${body}${tail}` : ''
)
}
type SharingSettings = { artifactSharingEnabled?: boolean; agentSkillSharingEnabled?: boolean }
/** Mirrors the publish commands' targets; any unreadable state hides the blocks (fail closed). */
export async function readEnabledHostCapabilities(
ctx: HandlerContext
): Promise<ReadonlySet<GuideHostCapability>> {
const enabled = new Set<GuideHostCapability>()
try {
const response = await ctx.client.call<{ settings?: SharingSettings }>(
'settings.get',
undefined,
{ timeoutMs: 2_000 }
)
const settings = response.result?.settings
if (settings?.artifactSharingEnabled === true) {
enabled.add('artifact-publishing')
}
// `skills share` refuses forwarded and remote invocations, so their hosts cannot publish skills.
if (
settings?.agentSkillSharingEnabled === true &&
!process.env.ORCA_CLI_CWD &&
!ctx.client.isRemote
) {
enabled.add('skill-publishing')
}
} catch {
// Orca not running, sandboxed, or an older host; the publish commands still enforce the real gate.
}
return enabled
}
@@ -6,7 +6,6 @@ vi.mock('./bundled-skill-guides.js', () => ({
name: 'alpha',
description: 'Use when alpha work is needed.',
markdown: '# Alpha\n\nShort.\n',
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
aliases: ['legacy-alpha'],
references: [
{ name: 'first-gate', markdown: '# First gate\n\nDo the first thing.\n' },
@@ -17,7 +16,6 @@ vi.mock('./bundled-skill-guides.js', () => ({
name: 'zeta',
description: 'Use when zeta work is needed.',
markdown: '# Zeta\n',
fullMarkdown: '# Zeta\n',
aliases: [],
references: []
}
+11 -8
View File
@@ -21,15 +21,15 @@ vi.mock('./bundled-skill-guides.js', () => {
name: 'zeta',
description: 'Use when zeta work\nspans lines.',
markdown: '# Zeta\n',
fullMarkdown: '# Zeta\n\n## References\n\nZeta reference.\n',
aliases: []
aliases: [],
references: []
},
{
name: 'alpha',
description: 'Use when alpha work is needed.',
markdown: '# Alpha\n\nShort.\n',
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
aliases: ['legacy-alpha']
aliases: ['legacy-alpha'],
references: [{ name: 'deep', markdown: '# Deep\n\nFull.\n' }]
},
{
name: 'gamma',
@@ -37,8 +37,8 @@ vi.mock('./bundled-skill-guides.js', () => {
'Use when gamma work spans several sentences describing exactly how a ' +
'coding agent should decide whether gamma applies to the current task at hand.',
markdown: '# Gamma\n',
fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n',
aliases: []
aliases: [],
references: []
}
]
}
@@ -121,7 +121,9 @@ describe('orca skills CLI', () => {
await main(['skills', 'get', 'alpha', '--full'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe('# Alpha\n\nShort.\n\n## References\n\nFull.\n')
expect(stdoutText(stdoutSpy)).toBe(
'# Alpha\n\nShort.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n<!-- bundled-reference: references/deep.md -->\n\n# Deep\n\nFull.\n'
)
})
it('supports the canonical single-item show verb as an alias', async () => {
@@ -167,7 +169,8 @@ describe('orca skills CLI', () => {
{
name: 'alpha',
full: true,
markdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n'
markdown:
'# Alpha\n\nShort.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n<!-- bundled-reference: references/deep.md -->\n\n# Deep\n\nFull.\n'
},
null,
2
+2 -1
View File
@@ -27,7 +27,8 @@ function findSpecForInvocation(invocation: string): CommandSpec | undefined {
function collectGuideInvocations(): GuideInvocation[] {
const found: GuideInvocation[] = []
for (const guide of BUNDLED_SKILL_GUIDES) {
for (const match of guide.fullMarkdown.matchAll(CLI_INVOCATION)) {
const documents = [guide.markdown, ...guide.references.map((reference) => reference.markdown)]
for (const match of documents.join('\n').matchAll(CLI_INVOCATION)) {
const invocation = match[1].trim()
const spec = findSpecForInvocation(invocation)
if (!spec) {
File diff suppressed because one or more lines are too long