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:
Jinjing
2026-08-10 18:06:49 -07:00
parent 2f221bdbfe
commit 05160cd08e
26 changed files with 788 additions and 84 deletions
File diff suppressed because one or more lines are too long
+158 -2
View File
@@ -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) => {
+31
View File
@@ -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)