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>
This commit is contained in:
Jinjing 2026-08-09 13:19:08 -07:00 committed by GitHub
parent 2dc172f666
commit 3ec48a74d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 788 additions and 84 deletions

View File

@ -104,6 +104,23 @@ describe('orca CLI skill guidance', () => {
expect(skill).not.toContain('sk_live_')
expect(skill).not.toContain('live_sk_')
})
// Publishing defaults to off, so an agent that follows the unconditional share workflow
// just loops on denials. The guide has to teach the opt-in and the recovery.
it('teaches the artifact publish opt-in and its recovery path', () => {
// Normalized so the assertions survive reflowing the guide's prose.
const skill = readSkill().replace(/\s+/gu, ' ')
expect(skill).toContain('**Publishing is off by default and only a human can turn it on.**')
expect(skill).toContain('Settings → Artifacts')
expect(skill).toContain('Allow publishing public artifact links')
expect(skill).toContain('artifact_sharing_disabled')
expect(skill).toContain('There is no CLI or RPC way to grant it')
expect(skill).toContain('Do not retry')
// The gate is device-wide, and revocation surfaces stay reachable.
expect(skill).toContain('every caller on the device, agent or human')
expect(skill).toContain('`list`, `unshare`, and `delete` are never gated')
})
})
describe('orca CLI install stub', () => {

View File

@ -232,6 +232,21 @@ Artifacts publish HTML or Markdown files through the signed-in Orca account. The
share URL is viewable without signing in; creating, listing, updating, and deleting
artifacts require the active Orca profile to be signed in.
**Publishing is off by default and only a human can turn it on.** `share` and `update` are
gated by a device-wide capability that the user grants in the Orca desktop app under
Settings → Artifacts ("Allow publishing public artifact links"). The gate applies to every
caller on the device, agent or human. There is no CLI or RPC way to grant it — do not try.
`list`, `unshare`, and `delete` are never gated, so old links stay auditable and revocable.
`share` and `update` check the capability before reading the file, so a denial costs one
small round trip rather than an upload-sized payload.
When a share is denied, the CLI fails with code `artifact_sharing_disabled` and prints the
recovery steps. Do not retry — the answer will not change until a human acts. Tell the user
to open Settings → Artifacts in the Orca desktop app on this device, turn on "Allow
publishing public artifact links", and then re-run the command. If they do not want to grant
it, deliver the file locally instead.
```text
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json

File diff suppressed because one or more lines are too long

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) => {

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)

View File

@ -47,7 +47,7 @@ function createResponse(slug: string): Response {
async function setup(): Promise<ArtifactCloudService> {
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-races-'))
createdPaths.push(path)
return new ArtifactCloudService(path)
return new ArtifactCloudService(path, () => true)
}
afterEach(async () => {

View File

@ -20,6 +20,12 @@ import {
tombstoneCloudSession
} from '../orca-profiles/profile-cloud-session-mutation'
import { saveOrcaCloudSession } from '../orca-profiles/profile-cloud-session-store'
import {
ARTIFACT_SHARING_DISABLED_CODE,
ARTIFACT_SHARING_DISABLED_MESSAGE,
isArtifactSharingEnabled
} from '../../shared/artifact-sharing-gate'
import { getDefaultSettings } from '../../shared/constants'
import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = []
@ -60,7 +66,7 @@ function createResponse(slug = 'artifact-a', expiresAt = '2026-09-06T00:00:00.00
)
}
async function setup(): Promise<{
async function setup(sharingEnabled: { value: boolean } = { value: true }): Promise<{
userDataPath: string
profileId: string
service: ArtifactCloudService
@ -73,7 +79,7 @@ async function setup(): Promise<{
return {
userDataPath,
profileId: active.profile.id,
service: new ArtifactCloudService(userDataPath)
service: new ArtifactCloudService(userDataPath, () => sharingEnabled.value)
}
}
@ -314,6 +320,87 @@ describe('ArtifactCloudService record authorization', () => {
})
})
describe('ArtifactCloudService publish capability gate', () => {
it('ships with the capability off so a fresh profile denies agent publishing', () => {
expect(isArtifactSharingEnabled(getDefaultSettings('/tmp'))).toBe(false)
})
it.each([
['share', (service: ArtifactCloudService) => service.share(writeRequest)],
['update', (service: ArtifactCloudService) => service.update(writeRequest)]
])('rejects %s without reaching the network when the capability is off', async (_name, call) => {
const { service } = await setup({ value: false })
const fetchMock = vi.fn().mockResolvedValue(createResponse())
vi.stubGlobal('fetch', fetchMock)
await expect(call(service)).rejects.toMatchObject({
code: ARTIFACT_SHARING_DISABLED_CODE,
data: { nextSteps: expect.arrayContaining([expect.stringContaining('Settings')]) }
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('denies the dev auth-token override too, so the gate is not bypassable by env', async () => {
const { service } = await setup({ value: false })
const fetchMock = vi.fn().mockResolvedValue(createResponse())
vi.stubGlobal('fetch', fetchMock)
await expect(service.share({ ...writeRequest, authToken: 'token-a' })).rejects.toThrow(
ARTIFACT_SHARING_DISABLED_MESSAGE
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('persists no share record for a denied share, so a later update stays denied', async () => {
const sharing = { value: false }
const { service } = await setup(sharing)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(createResponse()))
await expect(service.share(writeRequest)).rejects.toThrow(ARTIFACT_SHARING_DISABLED_MESSAGE)
sharing.value = true
await expect(service.update(writeRequest)).rejects.toThrow(/has not been shared/)
})
it('keeps list, unshare, and delete working so links stay revocable after opting out', async () => {
const sharing = { value: true }
const { service } = await setup(sharing)
const fetchMock = vi
.fn()
.mockResolvedValueOnce(createResponse())
.mockResolvedValueOnce(
new Response(JSON.stringify({ artifacts: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
sharing.value = false
await expect(service.list({ apiUrl, authToken: 'token-a' })).resolves.toMatchObject({
status: 'ok'
})
await expect(
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
).resolves.toEqual({ status: 'ok', value: undefined })
})
it('re-reads the capability per call so revoking it stops the next share', async () => {
const sharing = { value: true }
const { service } = await setup(sharing)
const fetchMock = vi.fn().mockResolvedValue(createResponse())
vi.stubGlobal('fetch', fetchMock)
await service.share(writeRequest)
sharing.value = false
await expect(service.share({ ...writeRequest, sourceKey: '/repo/other.html' })).rejects.toThrow(
ARTIFACT_SHARING_DISABLED_MESSAGE
)
expect(fetchMock).toHaveBeenCalledOnce()
})
})
function requestHeader(
fetchMock: ReturnType<typeof vi.fn>,
index: number,

View File

@ -7,6 +7,7 @@ import type {
ArtifactListItem,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
@ -113,7 +114,15 @@ function explicitTokenAuthContext(
}
export class ArtifactCloudService {
constructor(private readonly userDataPath: string) {}
/**
* `isSharingEnabled` is the publish capability gate. It is read per call, never cached, so
* revoking it in Settings takes effect on the next request. List, unshare, and delete stay
* ungated: a user who turns publishing off must still be able to audit and revoke old links.
*/
constructor(
private readonly userDataPath: string,
private readonly isSharingEnabled: () => boolean
) {}
list(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
return this.withAuth(options, async (token, apiUrl) => {
@ -122,7 +131,10 @@ export class ArtifactCloudService {
})
}
share(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
// Why async: the gate must surface as a rejection, not a synchronous throw, so every caller's
// promise chain handles it the same way.
async share(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
assertArtifactSharingAllowed(this.isSharingEnabled)
const idempotencyKey = randomUUID()
return this.withAuth(request, async (token, apiUrl, auth) => {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
@ -142,7 +154,8 @@ export class ArtifactCloudService {
})
}
update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
async update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
assertArtifactSharingAllowed(this.isSharingEnabled)
return this.withAuth(request, async (token, apiUrl, auth) => {
const record = getArtifactShareRecord(
auth.profileId,

View File

@ -67,6 +67,7 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import {
fingerprintOrchestrationPeer,
@ -2565,7 +2566,11 @@ void app.whenReady().then(async () => {
: undefined
})
runtimeService.setAutomationService(automations)
runtimeService.setArtifactService(new ArtifactCloudService(app.getPath('userData')))
runtimeService.setArtifactService(
new ArtifactCloudService(app.getPath('userData'), () =>
isArtifactSharingEnabled(store?.getSettings())
)
)
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.

View File

@ -5779,6 +5779,10 @@ export class Store {
if ('showMenuBarIcon' in updates) {
sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true
}
// Why: the artifact publish capability must be an exact boolean on disk; no truthy value grants it.
if ('artifactSharingEnabled' in updates) {
sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true
}
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}

View File

@ -10,6 +10,7 @@ import {
normalizeTerminalTitle
} from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { sortDirEntries } from '../../shared/file-name-sort'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
@ -1135,6 +1136,7 @@ type RuntimeStore = {
minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides']
artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled']
terminalQuickCommands?: GlobalSettings['terminalQuickCommands']
gitlabProjects?: GlobalSettings['gitlabProjects']
mobileAutoRestoreFitMs?: number | null
@ -3562,6 +3564,9 @@ export class OrcaRuntimeService {
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'prBotAuthorOverrides'
// Read-only on purpose: clients preflight the publish capability here, but SettingsUpdate
// still omits the key so no RPC caller can grant it to itself.
| 'artifactSharingEnabled'
> {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
@ -3584,7 +3589,8 @@ export class OrcaRuntimeService {
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? []
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings)
}
}

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mapRuntimeError } from './errors'
import {
ARTIFACT_SHARING_DISABLED_CODE,
ARTIFACT_SHARING_DISABLED_MESSAGE,
ArtifactSharingDisabledError
} from '../../../shared/artifact-sharing-gate'
class LineageError extends Error {
code = 'LINEAGE_PARENT_NOT_FOUND'
@ -177,3 +182,18 @@ describe('mapRuntimeError', () => {
})
})
})
describe('artifact sharing denial', () => {
it('reaches the CLI with its code, message, and next steps intact', () => {
expect(
mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new ArtifactSharingDisabledError())
).toMatchObject({
ok: false,
error: {
code: ARTIFACT_SHARING_DISABLED_CODE,
message: ARTIFACT_SHARING_DISABLED_MESSAGE,
data: { nextSteps: expect.arrayContaining([expect.stringContaining('Settings')]) }
}
})
})
})

View File

@ -7,6 +7,7 @@ import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error
import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority'
import { ARTIFACT_SHARING_DISABLED_CODE } from '../../../shared/artifact-sharing-gate'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@ -96,7 +97,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'answer_conflict',
'stale_delivery',
'waiter_exists',
'invalid_argument'
'invalid_argument',
ARTIFACT_SHARING_DISABLED_CODE
])
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { OrcaRuntimeService } from '../../orca-runtime'
import { SettingsUpdate } from './client-ui-schemas'
function runtimeWithSharing(artifactSharingEnabled: unknown): OrcaRuntimeService {
return new OrcaRuntimeService({
getSettings: () => ({ ...getDefaultSettings('/tmp'), artifactSharingEnabled })
} as never)
}
// Why: the publish gate is only real if an agent cannot grant it to itself. The RPC settings
// surface — which the CLI, relay, and mobile clients all reach — must reject the key outright.
describe('artifact publish capability cannot be granted over RPC', () => {
it('rejects settings.update attempts to turn on artifactSharingEnabled', () => {
expect(SettingsUpdate.safeParse({ artifactSharingEnabled: true }).success).toBe(false)
})
it('rejects the deprecated artifactsEnabled alias too', () => {
expect(SettingsUpdate.safeParse({ artifactsEnabled: true }).success).toBe(false)
})
it('still accepts an unrelated allowlisted setting', () => {
expect(SettingsUpdate.safeParse({ compactWorktreeCards: true }).success).toBe(true)
})
// Reading is the other half of the contract: clients preflight the capability over settings.get
// so a denial costs one small round trip instead of an upload-sized payload.
it('publishes the capability read-only through settings.get', () => {
expect(runtimeWithSharing(true).getClientSettings().artifactSharingEnabled).toBe(true)
expect(runtimeWithSharing(false).getClientSettings().artifactSharingEnabled).toBe(false)
expect(runtimeWithSharing(undefined).getClientSettings().artifactSharingEnabled).toBe(false)
})
it('reports the projection fail-closed, so a truthy disk value never reads as granted', () => {
expect(runtimeWithSharing('yes').getClientSettings().artifactSharingEnabled).toBe(false)
})
})

View File

@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
refreshAuth: vi.fn(),
rpc: vi.fn(),
settings: { skipDeleteArtifactConfirm: false } as Record<string, unknown>,
settings: {
artifactSharingEnabled: true,
skipDeleteArtifactConfirm: false
} as Record<string, unknown> | null,
updateSettings: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
@ -84,7 +87,7 @@ describe('ArtifactsPage', () => {
mocks.confirm.mockReset()
mocks.refreshAuth.mockReset()
mocks.rpc.mockReset()
mocks.settings = { skipDeleteArtifactConfirm: false }
mocks.settings = { artifactSharingEnabled: true, skipDeleteArtifactConfirm: false }
mocks.updateSettings.mockReset().mockResolvedValue(undefined)
mocks.openSettingsPage.mockReset()
mocks.openSettingsTarget.mockReset()
@ -198,6 +201,36 @@ describe('ArtifactsPage', () => {
screen.getByText('Ask your agent to share an HTML or Markdown file, and it will appear here.')
).toBeInTheDocument()
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Open Settings → Artifacts' })
).not.toBeInTheDocument()
})
it('sends the user to Settings instead of an agent when publishing is off', async () => {
mocks.settings = { artifactSharingEnabled: false }
mocks.rpc.mockResolvedValue({ status: 'ok', value: { artifacts: [] } })
render(<ArtifactsPage />)
await screen.findByText('Publishing is turned off')
expect(screen.getByText(/Allow publishing in Settings → Artifacts/)).toBeInTheDocument()
expect(
screen.queryByText(
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
)
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Settings → Artifacts' }))
expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ pane: 'artifacts', repoId: null })
expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
})
it('keeps the neutral empty state until settings have loaded', async () => {
mocks.settings = null
mocks.rpc.mockResolvedValue({ status: 'ok', value: { artifacts: [] } })
render(<ArtifactsPage />)
await screen.findByText('No shared artifacts')
expect(screen.queryByText('Publishing is turned off')).not.toBeInTheDocument()
})
it('loads each cursor once and appends the next artifact page', async () => {

View File

@ -24,6 +24,9 @@ export default function ArtifactsPage(): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const confirm = useConfirmationDialog()
// Why: publishing is off by default, so "ask your agent to share" is a dead end until the
// capability is granted. Only claim that once settings have actually loaded.
const publishingBlocked = settings ? settings.artifactSharingEnabled !== true : false
const [deleting, setDeleting] = useState<{ identity: string; slug: string } | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const signedIn = authStatus?.state === 'connected'
@ -306,19 +309,49 @@ export default function ArtifactsPage(): React.JSX.Element {
'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available'
)
: translate('auto.components.artifacts.ArtifactsPage.empty', 'No shared artifacts')}
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOff',
'Publishing is turned off'
)
: translate(
'auto.components.artifacts.ArtifactsPage.empty',
'No shared artifacts'
)}
</h2>
<p className="text-xs text-muted-foreground">
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
{nextCursor
? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
)}
: publishingBlocked
? translate(
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.'
)
: translate(
'auto.components.artifacts.ArtifactsPage.emptyCopy',
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
)}
</p>
{!nextCursor && publishingBlocked ? (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => {
openSettingsTarget({ pane: 'artifacts', repoId: null })
openSettingsPage()
}}
>
{translate(
'auto.components.artifacts.ArtifactsPage.openArtifactsSettings',
'Open Settings → Artifacts'
)}
</Button>
) : null}
{nextCursor ? (
<Button
type="button"

View File

@ -15,7 +15,8 @@ const mocks = vi.hoisted(() => ({
configured: true,
state: 'connected'
} as Record<string, unknown> | null,
orcaProfileConnecting: false
orcaProfileConnecting: false,
isWebClient: false
}
}))
@ -23,6 +24,10 @@ vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => mocks.state.isWebClient
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
@ -42,6 +47,7 @@ describe('ArtifactsSettingsPane', () => {
mocks.openArtifactsPage.mockReset()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
mocks.state.orcaProfileConnecting = false
mocks.state.isWebClient = false
})
afterEach(cleanup)
@ -130,4 +136,88 @@ describe('ArtifactsSettingsPane', () => {
await user.click(openButton)
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
})
it('shows the publish capability off by default and describes it as device-wide', () => {
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
expect(
screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
).toHaveAttribute('aria-checked', 'false')
expect(screen.getByText(/your agents and the orca CLI/)).toBeInTheDocument()
expect(screen.getByText(/mint links anyone with the URL can open/)).toBeInTheDocument()
expect(screen.getByText(/does not delete existing links/)).toBeInTheDocument()
})
it('grants and revokes the publish capability through the toggle', async () => {
const user = userEvent.setup()
const updateSettings = vi.fn()
const { rerender } = render(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), artifactSharingEnabled: false }}
updateSettings={updateSettings}
/>
)
await user.click(screen.getByRole('switch', { name: 'Allow publishing public artifact links' }))
expect(updateSettings).toHaveBeenCalledWith({ artifactSharingEnabled: true })
rerender(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), artifactSharingEnabled: true }}
updateSettings={updateSettings}
/>
)
const toggle = screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(toggle)
expect(updateSettings).toHaveBeenLastCalledWith({ artifactSharingEnabled: false })
})
it('makes the capability read-only on web, where the grant never reaches the host', async () => {
const user = userEvent.setup()
const updateSettings = vi.fn()
mocks.state.isWebClient = true
render(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), artifactSharingEnabled: true }}
updateSettings={updateSettings}
/>
)
const toggle = screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
// Mirrors the host value so web never claims a capability the host is not enforcing.
expect(toggle).toHaveAttribute('aria-checked', 'true')
expect(toggle).toBeDisabled()
await user.click(toggle)
expect(updateSettings).not.toHaveBeenCalled()
expect(screen.getByText(/Desktop only/)).toBeInTheDocument()
})
it('leads with the opt-in step while publishing is off, and drops it once granted', () => {
const { rerender } = render(
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
)
expect(screen.getByText('Allow publishing first')).toBeInTheDocument()
expect(screen.getByText(/artifact_sharing_disabled/)).toBeInTheDocument()
expect(screen.getByText(/Publishing is off, so nothing on this device/)).toBeInTheDocument()
rerender(
<ArtifactsSettingsPane
settings={{ ...getDefaultSettings('/tmp'), artifactSharingEnabled: true }}
updateSettings={vi.fn()}
/>
)
expect(screen.queryByText('Allow publishing first')).not.toBeInTheDocument()
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
})
it('points web clients at the desktop app for the opt-in step', () => {
mocks.state.isWebClient = true
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
expect(
screen.getByText(/Open Settings → Artifacts in the Orca desktop app on the host device/)
).toBeInTheDocument()
})
})

View File

@ -4,8 +4,11 @@ import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '@/components/ui/button'
import { SettingsSwitchRow } from './SettingsFormControls'
import { useAppStore } from '@/store'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n'
type HowToStep = { key: string; title: string; description: string }
export function ArtifactsSettingsPane({
settings,
updateSettings
@ -19,6 +22,10 @@ export function ArtifactsSettingsPane({
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signedIn = authStatus?.state === 'connected'
// Why: the capability lives in the desktop host's store and is deliberately absent from the
// settings.update allowlist, so a web client can only mirror it — never grant it.
const isWebClient = isWebClientLocation()
const sharingEnabled = settings.artifactSharingEnabled === true
useEffect(() => {
if (!authStatus) {
@ -26,8 +33,78 @@ export function ArtifactsSettingsPane({
}
}, [authStatus, fetchAuthStatus])
const howToSteps: HowToStep[] = [
...(sharingEnabled
? []
: [
{
key: 'enable',
title: translate(
'auto.components.settings.artifacts.enableStepTitle',
'Allow publishing first'
),
description: isWebClient
? translate(
'auto.components.settings.artifacts.enableStepWebDescription',
'Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.'
)
: translate(
'auto.components.settings.artifacts.enableStepDescription',
'Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.'
)
}
]),
{
key: 'share',
title: translate(
'auto.components.settings.artifacts.shareStepTitle',
'Ask your agent to share it'
),
description: translate(
'auto.components.settings.artifacts.shareStepDescription',
'For example: “Share this HTML mock as an artifact.”'
)
},
{
key: 'link',
title: translate('auto.components.settings.artifacts.linkStepTitle', 'Share the public link'),
description: translate(
'auto.components.settings.artifacts.linkStepDescription',
'Your agent returns a link that anyone with the URL can view.'
)
},
{
key: 'manage',
title: translate('auto.components.settings.artifacts.manageStepTitle', 'Manage it in Orca'),
description: translate(
'auto.components.settings.artifacts.manageStepDescription',
'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
)
}
]
return (
<div className="divide-y divide-border">
<SettingsSwitchRow
label={translate(
'auto.components.settings.artifacts.allowPublishing',
'Allow publishing public artifact links'
)}
description={
isWebClient
? translate(
'auto.components.settings.artifacts.allowPublishingWebDescription',
'Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.'
)
: translate(
'auto.components.settings.artifacts.allowPublishingDescription',
'Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.'
)
}
checked={sharingEnabled}
disabled={isWebClient}
onChange={() => void updateSettings({ artifactSharingEnabled: !sharingEnabled })}
/>
<SettingsSwitchRow
label={translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button')}
description={translate(
@ -73,71 +150,30 @@ export function ArtifactsSettingsPane({
{translate('auto.components.settings.artifacts.howToTitle', 'How to use Artifacts')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.howToDescription',
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
)}
{sharingEnabled
? translate(
'auto.components.settings.artifacts.howToDescription',
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
)
: translate(
'auto.components.settings.artifacts.howToDescriptionDisabled',
'Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.'
)}
</p>
</div>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
1
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.shareStepTitle',
'Ask your agent to share it'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.shareStepDescription',
'For example: “Share this HTML mock as an artifact.”'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
2
</span>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.linkStepTitle',
'Share the public link'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.linkStepDescription',
'Your agent returns a link that anyone with the URL can view.'
)}
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
3
</span>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.artifacts.manageStepTitle',
'Manage it in Orca'
)}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.artifacts.manageStepDescription',
'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
)}
</p>
</div>
</li>
{howToSteps.map((step, index) => (
<li key={step.key} className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">{step.title}</p>
<p className="text-xs leading-relaxed text-muted-foreground">{step.description}</p>
</div>
</li>
))}
</ol>
<Button

View File

@ -108,6 +108,7 @@ type SettingsSwitchRowProps = {
onChange: () => void
className?: string
ariaLabel?: string
disabled?: boolean
}
export function SettingsSwitchRow({
@ -116,7 +117,8 @@ export function SettingsSwitchRow({
checked,
onChange,
className,
ariaLabel
ariaLabel,
disabled
}: SettingsSwitchRowProps): React.JSX.Element {
return (
<SettingsRow
@ -127,6 +129,7 @@ export function SettingsSwitchRow({
<SettingsSwitch
checked={checked}
onChange={onChange}
disabled={disabled}
ariaLabel={ariaLabel ?? (typeof label === 'string' ? label : undefined)}
/>
}

View File

@ -3,6 +3,26 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
'auto.components.settings.artifacts.allowPublishing',
'Allow publishing public artifact links'
),
description: translate(
'auto.components.settings.artifacts.allowPublishingSearchDescription',
'Let agents and the orca CLI upload files to your Orca account and mint public links.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.artifacts.keywordArtifacts', 'artifacts'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordShare', 'share'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordPublish', 'publish'),
...translateSearchKeyword('auto.components.settings.artifacts.keywordPublic', 'public'),
...translateSearchKeyword(
'auto.components.settings.artifacts.keywordPermission',
'permission'
)
]
},
{
title: translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button'),
description: translate(

View File

@ -10434,7 +10434,15 @@
"openArtifactsDescriptionV2": "Preview, copy, and manage links shared through your account.",
"signInTitle": "Sign in to share artifacts",
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.",
"signInAgain": "Sign in again"
"signInAgain": "Sign in again",
"enableStepTitle": "Allow publishing first",
"enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.",
"enableStepDescription": "Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.",
"allowPublishing": "Allow publishing public artifact links",
"allowPublishingWebDescription": "Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.",
"allowPublishingDescription": "Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.",
"howToDescriptionDisabled": "Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.",
"allowPublishingSearchDescription": "Let agents and the orca CLI upload files to your Orca account and mint public links."
},
"orcaAccount": {
"connected": "Connected",
@ -14954,6 +14962,9 @@
"moreAvailable": "More artifacts are available",
"moreAvailableCopy": "Load the next page to continue.",
"loadMoreFailed": "Could not load more artifacts.",
"publishingOff": "Publishing is turned off",
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.",
"openArtifactsSettings": "Open Settings → Artifacts",
"loadedCountMore": "{{count}} loaded · more available",
"loadedCount": "{{count}} shared",
"retry": "Retry",

View File

@ -3736,6 +3736,11 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
result.settings.prBotAuthorOverrides
)
}
// Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never
// sends it back, so web shows what the host enforces instead of a local value it ignores.
if (typeof result.settings.artifactSharingEnabled === 'boolean') {
runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled
}
const next = mergeSettings(local, runtimeSettings)
writeStoredSettings(next)
return next

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from './constants'
import {
ARTIFACT_SHARING_DISABLED_CODE,
ArtifactSharingDisabledError,
assertArtifactSharingAllowed,
isArtifactSharingEnabled
} from './artifact-sharing-gate'
describe('artifact sharing capability gate', () => {
it('denies by default and for profiles written before the setting existed', () => {
expect(isArtifactSharingEnabled(getDefaultSettings('/tmp'))).toBe(false)
expect(isArtifactSharingEnabled({})).toBe(false)
expect(isArtifactSharingEnabled(null)).toBe(false)
expect(isArtifactSharingEnabled(undefined)).toBe(false)
})
it('requires an exact true, so a truthy value on disk cannot open the gate', () => {
expect(isArtifactSharingEnabled({ artifactSharingEnabled: true })).toBe(true)
expect(isArtifactSharingEnabled({ artifactSharingEnabled: 'yes' as never })).toBe(false)
expect(isArtifactSharingEnabled({ artifactSharingEnabled: 1 as never })).toBe(false)
})
it('throws a coded, actionable error when the capability is withheld', () => {
expect(() => assertArtifactSharingAllowed(() => false)).toThrow(ArtifactSharingDisabledError)
try {
assertArtifactSharingAllowed(() => false)
expect.unreachable('gate must throw')
} catch (error) {
expect(error).toMatchObject({
code: ARTIFACT_SHARING_DISABLED_CODE,
data: { nextSteps: expect.arrayContaining([expect.stringContaining('Settings')]) }
})
}
expect(() => assertArtifactSharingAllowed(() => true)).not.toThrow()
})
})

View File

@ -0,0 +1,39 @@
// Why: publishing an artifact mints a URL anyone can open, so agents get the capability only
// after the user grants it. The gate lives here so main, the CLI, and Settings share one contract.
import type { GlobalSettings } from './types'
export const ARTIFACT_SHARING_DISABLED_CODE = 'artifact_sharing_disabled'
// Why device-wide wording: the gate has no caller identity, so `orca artifacts share` typed by a
// human is denied exactly like an agent's. Copy that blames agents alone would misdescribe it.
export const ARTIFACT_SHARING_DISABLED_MESSAGE =
'Publishing artifacts is off for this device. Nothing running here — agents or the orca CLI — can mint public artifact links until you allow it.'
export const ARTIFACT_SHARING_DISABLED_NEXT_STEPS: readonly string[] = [
'Open Settings → Artifacts in the Orca desktop app on this device.',
'Turn on "Allow publishing public artifact links".',
'Run the share command again.'
]
export class ArtifactSharingDisabledError extends Error {
readonly code = ARTIFACT_SHARING_DISABLED_CODE
readonly data = { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
constructor() {
super(ARTIFACT_SHARING_DISABLED_MESSAGE)
this.name = 'ArtifactSharingDisabledError'
}
}
/** Absent or non-`true` denies: an unmigrated profile must not inherit the capability. */
export function isArtifactSharingEnabled(
settings: Pick<GlobalSettings, 'artifactSharingEnabled'> | null | undefined
): boolean {
return settings?.artifactSharingEnabled === true
}
export function assertArtifactSharingAllowed(isEnabled: () => boolean): void {
if (!isEnabled()) {
throw new ArtifactSharingDisabledError()
}
}

View File

@ -278,6 +278,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTasksButton: true,
showAutomationsButton: true,
artifactsEnabled: true,
artifactSharingEnabled: false,
showArtifactsButton: false,
showMobileButton: true,
showPinnedWorktreesInGroups: false,

View File

@ -2903,6 +2903,8 @@ export type GlobalSettings = {
showAutomationsButton?: boolean
/** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */
artifactsEnabled?: boolean
/** Capability gate for agent-driven publishing; off until granted, enforced in main, not just the UI. */
artifactSharingEnabled?: boolean
/** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */
showArtifactsButton?: boolean
/** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */