mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Gate artifact publishing behind off-by-default capability (#13368)
* fix(artifacts): gate agent artifact publishing behind an off-by-default capability
Public artifact sharing was reachable by any agent through `orca artifacts
share`: the Artifacts settings toggle only controlled sidebar visibility, and
nothing in the main process checked a capability before minting a public URL.
Add `artifactSharingEnabled` (default off) and enforce it in
ArtifactCloudService.share/update — before auth, network, or the share-record
write — so the CLI, relay-forwarded remote CLI, and IPC paths are all denied.
The denial carries a stable `artifact_sharing_disabled` code plus next steps
through the RPC error allowlist, so the CLI prints actionable guidance.
list, unshare, and delete stay ungated: turning publishing off must not strand
already-published links. The capability is absent from the `settings.update`
RPC schema, so an agent cannot grant it to itself — only the desktop UI can.
Co-authored-by: Orca <help@stably.ai>
* fix(artifacts): gate agent artifact publishing behind an off-by-default
Publishing is blocked until enabled in Settings → Artifacts. CLI preflights the capability before reading files to avoid unnecessary uploads. RPC surface rejects capability grants so callers cannot self-grant. UI shows opt-in workflow and recovery path when publishing is off. Web clients mirror the host's setting read-only.
---------
Co-authored-by: Orca <help@stably.ai>
(cherry picked from commit 5b377a9c0f)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,6 +5,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ArtifactListItem } from '../../shared/artifacts'
|
||||
import { ARTIFACT_HANDLERS } from './artifacts'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
ARTIFACT_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/artifact-sharing-gate'
|
||||
import { RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { reportCliError } from '../format'
|
||||
|
||||
const item: ArtifactListItem = {
|
||||
artifact: {
|
||||
@@ -77,7 +84,12 @@ describe('artifact CLI handlers', () => {
|
||||
const handle = await open(join(cwd, 'oversized.html'), 'w')
|
||||
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
|
||||
await handle.close()
|
||||
const call = vi.fn()
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { artifactSharingEnabled: true } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS['artifacts share']!({
|
||||
@@ -87,7 +99,8 @@ describe('artifact CLI handlers', () => {
|
||||
json: false
|
||||
})
|
||||
).rejects.toThrow(/too large/)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
// The capability preflight is the only permitted call; the oversized body never ships.
|
||||
expect(call).not.toHaveBeenCalledWith('artifacts.share', expect.anything())
|
||||
})
|
||||
|
||||
it('passes an opaque list cursor through and prints the next cursor', async () => {
|
||||
@@ -115,6 +128,149 @@ describe('artifact CLI handlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['artifacts share', 'artifacts update'])(
|
||||
'denies `%s` from the capability preflight without reading or shipping the file',
|
||||
async (command) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { artifactSharingEnabled: false } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['omits the capability field', {}],
|
||||
['cannot answer the preflight', null]
|
||||
])('still attempts the publish RPC when the host %s', async (_label, settings) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'settings.get') {
|
||||
if (!settings) {
|
||||
return Promise.reject(new Error('unsupported_method'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'request-2',
|
||||
ok: true,
|
||||
result: { status: 'ok', value: item },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await ARTIFACT_HANDLERS['artifacts share']!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'artifacts.share',
|
||||
expect.objectContaining({ content: '<h1>Hi</h1>' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['artifacts share', 'artifacts update'])(
|
||||
'surfaces the capability denial from `%s` with actionable next steps',
|
||||
async (command) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
message: ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
).rejects.toMatchObject({ code: ARTIFACT_SHARING_DISABLED_CODE })
|
||||
|
||||
// The CLI entry point reports the thrown error; assert the rendered text is actionable.
|
||||
reportCliError(
|
||||
await ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
}).catch((error: unknown) => error),
|
||||
false,
|
||||
{ commandPath: command.split(' ') }
|
||||
)
|
||||
const rendered = String(errorLog.mock.calls.at(-1)?.[0])
|
||||
expect(rendered).toContain(ARTIFACT_SHARING_DISABLED_MESSAGE)
|
||||
expect(rendered).toContain('Settings → Artifacts')
|
||||
}
|
||||
)
|
||||
|
||||
it('reports the denial with a stable code in --json mode', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
message: ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
const error = await ARTIFACT_HANDLERS['artifacts share']!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: true
|
||||
}).catch((thrown: unknown) => thrown)
|
||||
reportCliError(error, true)
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).error).toMatchObject({
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['environment', 'pairing-code'])(
|
||||
'rejects explicit remote selector --%s',
|
||||
async (flag) => {
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
REMOTE_ARTIFACT_INPUT_ENV
|
||||
} from '../../shared/artifact-cli-bridge'
|
||||
import { readArtifactFileWithinLimit } from '../../shared/artifact-file-read'
|
||||
import {
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
ARTIFACT_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/artifact-sharing-gate'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
|
||||
@@ -76,6 +81,31 @@ async function readStdinWithinLimit(maxBytes: number): Promise<string> {
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing is off by default, so denial is the common outcome. A tiny `settings.get` read
|
||||
* answers it before we load (or pipe in) up to the full RPC byte budget the host would reject.
|
||||
* Only an explicit `false` denies: a host predating the capability omits the field entirely,
|
||||
* and an unreachable host stays the publish RPC's problem, not the preflight's.
|
||||
*/
|
||||
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
|
||||
let enabled: unknown
|
||||
try {
|
||||
const response = await ctx.client.call<{
|
||||
settings?: { artifactSharingEnabled?: boolean }
|
||||
}>('settings.get')
|
||||
enabled = response.result?.settings?.artifactSharingEnabled
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (enabled === false) {
|
||||
throw new RuntimeClientError(
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
{ nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRequest> {
|
||||
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
|
||||
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
|
||||
@@ -83,6 +113,7 @@ async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRe
|
||||
if (!contentType) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.')
|
||||
}
|
||||
await preflightPublishCapability(ctx)
|
||||
const localRead = remoteInput
|
||||
? null
|
||||
: await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
|
||||
Reference in New Issue
Block a user