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('sk_live_')
expect(skill).not.toContain('live_sk_') 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', () => { 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 share URL is viewable without signing in; creating, listing, updating, and deleting
artifacts require the active Orca profile to be signed in. 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 ```text
ORCA artifacts share <file> --json ORCA artifacts share <file> --json
ORCA artifacts update <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 type { ArtifactListItem } from '../../shared/artifacts'
import { ARTIFACT_HANDLERS } from './artifacts' import { ARTIFACT_HANDLERS } from './artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/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 = { const item: ArtifactListItem = {
artifact: { artifact: {
@ -77,7 +84,12 @@ describe('artifact CLI handlers', () => {
const handle = await open(join(cwd, 'oversized.html'), 'w') const handle = await open(join(cwd, 'oversized.html'), 'w')
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1) await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
await handle.close() 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( await expect(
ARTIFACT_HANDLERS['artifacts share']!({ ARTIFACT_HANDLERS['artifacts share']!({
@ -87,7 +99,8 @@ describe('artifact CLI handlers', () => {
json: false json: false
}) })
).rejects.toThrow(/too large/) ).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 () => { 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'])( it.each(['environment', 'pairing-code'])(
'rejects explicit remote selector --%s', 'rejects explicit remote selector --%s',
async (flag) => { async (flag) => {

View File

@ -12,6 +12,11 @@ import {
REMOTE_ARTIFACT_INPUT_ENV REMOTE_ARTIFACT_INPUT_ENV
} from '../../shared/artifact-cli-bridge' } from '../../shared/artifact-cli-bridge'
import { readArtifactFileWithinLimit } from '../../shared/artifact-file-read' 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 type { CommandHandler, HandlerContext } from '../dispatch'
import { RuntimeClientError } from '../runtime-client' import { RuntimeClientError } from '../runtime-client'
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format' import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
@ -76,6 +81,31 @@ async function readStdinWithinLimit(maxBytes: number): Promise<string> {
return Buffer.concat(chunks).toString('utf8') 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> { async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRequest> {
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV]) const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file')) const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
@ -83,6 +113,7 @@ async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRe
if (!contentType) { if (!contentType) {
throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.') throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.')
} }
await preflightPublishCapability(ctx)
const localRead = remoteInput const localRead = remoteInput
? null ? null
: await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES) : await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)

View File

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

View File

@ -20,6 +20,12 @@ import {
tombstoneCloudSession tombstoneCloudSession
} from '../orca-profiles/profile-cloud-session-mutation' } from '../orca-profiles/profile-cloud-session-mutation'
import { saveOrcaCloudSession } from '../orca-profiles/profile-cloud-session-store' 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' import { ArtifactCloudService } from './artifact-cloud-service'
const createdPaths: string[] = [] 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 userDataPath: string
profileId: string profileId: string
service: ArtifactCloudService service: ArtifactCloudService
@ -73,7 +79,7 @@ async function setup(): Promise<{
return { return {
userDataPath, userDataPath,
profileId: active.profile.id, 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( function requestHeader(
fetchMock: ReturnType<typeof vi.fn>, fetchMock: ReturnType<typeof vi.fn>,
index: number, index: number,

View File

@ -7,6 +7,7 @@ import type {
ArtifactListItem, ArtifactListItem,
ArtifactWriteRequest ArtifactWriteRequest
} from '../../shared/artifacts' } from '../../shared/artifacts'
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client' import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
@ -113,7 +114,15 @@ function explicitTokenAuthContext(
} }
export class ArtifactCloudService { 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>> { list(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
return this.withAuth(options, async (token, apiUrl) => { 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() const idempotencyKey = randomUUID()
return this.withAuth(request, async (token, apiUrl, auth) => { return this.withAuth(request, async (token, apiUrl, auth) => {
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', { 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) => { return this.withAuth(request, async (token, apiUrl, auth) => {
const record = getArtifactShareRecord( const record = getArtifactShareRecord(
auth.profileId, auth.profileId,

View File

@ -67,6 +67,7 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications' import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
import { ArtifactCloudService } from './artifacts/artifact-cloud-service' import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import { import {
fingerprintOrchestrationPeer, fingerprintOrchestrationPeer,
@ -2565,7 +2566,11 @@ void app.whenReady().then(async () => {
: undefined : undefined
}) })
runtimeService.setAutomationService(automations) 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.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({ 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. // 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) { if ('showMenuBarIcon' in updates) {
sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true 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) { if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
} }

View File

@ -10,6 +10,7 @@ import {
normalizeTerminalTitle normalizeTerminalTitle
} from '../../shared/agent-detection' } from '../../shared/agent-detection'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { sortDirEntries } from '../../shared/file-name-sort' import { sortDirEntries } from '../../shared/file-name-sort'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
@ -1135,6 +1136,7 @@ type RuntimeStore = {
minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides']
artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled']
terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands']
gitlabProjects?: GlobalSettings['gitlabProjects'] gitlabProjects?: GlobalSettings['gitlabProjects']
mobileAutoRestoreFitMs?: number | null mobileAutoRestoreFitMs?: number | null
@ -3562,6 +3564,9 @@ export class OrcaRuntimeService {
| 'minimaxGroupId' | 'minimaxGroupId'
| 'minimaxUsageModels' | 'minimaxUsageModels'
| 'prBotAuthorOverrides' | '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) { if (!this.store?.getSettings) {
throw new Error('runtime_unavailable') throw new Error('runtime_unavailable')
@ -3584,7 +3589,8 @@ export class OrcaRuntimeService {
compactWorktreeCards: settings.compactWorktreeCards === true, compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '', minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general', 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 { describe, expect, it } from 'vitest'
import { mapRuntimeError } from './errors' import { mapRuntimeError } from './errors'
import {
ARTIFACT_SHARING_DISABLED_CODE,
ARTIFACT_SHARING_DISABLED_MESSAGE,
ArtifactSharingDisabledError
} from '../../../shared/artifact-sharing-gate'
class LineageError extends Error { class LineageError extends Error {
code = 'LINEAGE_PARENT_NOT_FOUND' 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 { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access' import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority' 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 { export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return { return {
@ -96,7 +97,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'answer_conflict', 'answer_conflict',
'stale_delivery', 'stale_delivery',
'waiter_exists', 'waiter_exists',
'invalid_argument' 'invalid_argument',
ARTIFACT_SHARING_DISABLED_CODE
]) ])
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { 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(), confirm: vi.fn(),
refreshAuth: vi.fn(), refreshAuth: vi.fn(),
rpc: 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(), updateSettings: vi.fn(),
openSettingsPage: vi.fn(), openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(), openSettingsTarget: vi.fn(),
@ -84,7 +87,7 @@ describe('ArtifactsPage', () => {
mocks.confirm.mockReset() mocks.confirm.mockReset()
mocks.refreshAuth.mockReset() mocks.refreshAuth.mockReset()
mocks.rpc.mockReset() mocks.rpc.mockReset()
mocks.settings = { skipDeleteArtifactConfirm: false } mocks.settings = { artifactSharingEnabled: true, skipDeleteArtifactConfirm: false }
mocks.updateSettings.mockReset().mockResolvedValue(undefined) mocks.updateSettings.mockReset().mockResolvedValue(undefined)
mocks.openSettingsPage.mockReset() mocks.openSettingsPage.mockReset()
mocks.openSettingsTarget.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.') screen.getByText('Ask your agent to share an HTML or Markdown file, and it will appear here.')
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.queryByText(/orca artifacts share/)).not.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 () => { 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 settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings) const updateSettings = useAppStore((state) => state.updateSettings)
const confirm = useConfirmationDialog() 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 [deleting, setDeleting] = useState<{ identity: string; slug: string } | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null) const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const signedIn = authStatus?.state === 'connected' const signedIn = authStatus?.state === 'connected'
@ -306,19 +309,49 @@ export default function ArtifactsPage(): React.JSX.Element {
'auto.components.artifacts.ArtifactsPage.moreAvailable', 'auto.components.artifacts.ArtifactsPage.moreAvailable',
'More artifacts are available' '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> </h2>
<p className="text-xs text-muted-foreground"> <p className="max-w-sm text-xs leading-5 text-muted-foreground">
{nextCursor {nextCursor
? translate( ? translate(
'auto.components.artifacts.ArtifactsPage.moreAvailableCopy', 'auto.components.artifacts.ArtifactsPage.moreAvailableCopy',
'Load the next page to continue.' 'Load the next page to continue.'
) )
: translate( : publishingBlocked
'auto.components.artifacts.ArtifactsPage.emptyCopy', ? translate(
'Ask your agent to share an HTML or Markdown file, and it will appear here.' '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> </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 ? ( {nextCursor ? (
<Button <Button
type="button" type="button"

View File

@ -15,7 +15,8 @@ const mocks = vi.hoisted(() => ({
configured: true, configured: true,
state: 'connected' state: 'connected'
} as Record<string, unknown> | null, } 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 translate: (_key: string, fallback: string) => fallback
})) }))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => mocks.state.isWebClient
}))
vi.mock('@/store', () => ({ vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) => useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ selector({
@ -42,6 +47,7 @@ describe('ArtifactsSettingsPane', () => {
mocks.openArtifactsPage.mockReset() mocks.openArtifactsPage.mockReset()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' } mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
mocks.state.orcaProfileConnecting = false mocks.state.orcaProfileConnecting = false
mocks.state.isWebClient = false
}) })
afterEach(cleanup) afterEach(cleanup)
@ -130,4 +136,88 @@ describe('ArtifactsSettingsPane', () => {
await user.click(openButton) await user.click(openButton)
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce() 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 { Button } from '@/components/ui/button'
import { SettingsSwitchRow } from './SettingsFormControls' import { SettingsSwitchRow } from './SettingsFormControls'
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n' import { translate } from '@/i18n/i18n'
type HowToStep = { key: string; title: string; description: string }
export function ArtifactsSettingsPane({ export function ArtifactsSettingsPane({
settings, settings,
updateSettings updateSettings
@ -19,6 +22,10 @@ export function ArtifactsSettingsPane({
const connect = useAppStore((state) => state.connectCurrentOrcaProfile) const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus) const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
const signedIn = authStatus?.state === 'connected' 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(() => { useEffect(() => {
if (!authStatus) { if (!authStatus) {
@ -26,8 +33,78 @@ export function ArtifactsSettingsPane({
} }
}, [authStatus, fetchAuthStatus]) }, [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 ( return (
<div className="divide-y divide-border"> <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 <SettingsSwitchRow
label={translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button')} label={translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button')}
description={translate( description={translate(
@ -73,71 +150,30 @@ export function ArtifactsSettingsPane({
{translate('auto.components.settings.artifacts.howToTitle', 'How to use Artifacts')} {translate('auto.components.settings.artifacts.howToTitle', 'How to use Artifacts')}
</h3> </h3>
<p className="text-xs leading-relaxed text-muted-foreground"> <p className="text-xs leading-relaxed text-muted-foreground">
{translate( {sharingEnabled
'auto.components.settings.artifacts.howToDescription', ? translate(
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.' '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> </p>
</div> </div>
<ol className="space-y-3"> <ol className="space-y-3">
<li className="flex items-start gap-3"> {howToSteps.map((step, index) => (
<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"> <li key={step.key} className="flex items-start gap-3">
1 <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">
</span> {index + 1}
<div className="min-w-0 flex-1 space-y-0.5"> </span>
<p className="text-sm font-medium"> <div className="min-w-0 flex-1 space-y-0.5">
{translate( <p className="text-sm font-medium">{step.title}</p>
'auto.components.settings.artifacts.shareStepTitle', <p className="text-xs leading-relaxed text-muted-foreground">{step.description}</p>
'Ask your agent to share it' </div>
)} </li>
</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>
</ol> </ol>
<Button <Button

View File

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

View File

@ -3,6 +3,26 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords' import { translateSearchKeyword } from './settings-search-keywords'
export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [ 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'), title: translate('auto.components.settings.artifacts.showButton', 'Show Artifacts Button'),
description: translate( description: translate(

View File

@ -10434,7 +10434,15 @@
"openArtifactsDescriptionV2": "Preview, copy, and manage links shared through your account.", "openArtifactsDescriptionV2": "Preview, copy, and manage links shared through your account.",
"signInTitle": "Sign in to share artifacts", "signInTitle": "Sign in to share artifacts",
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.", "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": { "orcaAccount": {
"connected": "Connected", "connected": "Connected",
@ -14954,6 +14962,9 @@
"moreAvailable": "More artifacts are available", "moreAvailable": "More artifacts are available",
"moreAvailableCopy": "Load the next page to continue.", "moreAvailableCopy": "Load the next page to continue.",
"loadMoreFailed": "Could not load more artifacts.", "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", "loadedCountMore": "{{count}} loaded · more available",
"loadedCount": "{{count}} shared", "loadedCount": "{{count}} shared",
"retry": "Retry", "retry": "Retry",

View File

@ -3736,6 +3736,11 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
result.settings.prBotAuthorOverrides 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) const next = mergeSettings(local, runtimeSettings)
writeStoredSettings(next) writeStoredSettings(next)
return 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, showTasksButton: true,
showAutomationsButton: true, showAutomationsButton: true,
artifactsEnabled: true, artifactsEnabled: true,
artifactSharingEnabled: false,
showArtifactsButton: false, showArtifactsButton: false,
showMobileButton: true, showMobileButton: true,
showPinnedWorktreesInGroups: false, showPinnedWorktreesInGroups: false,

View File

@ -2903,6 +2903,8 @@ export type GlobalSettings = {
showAutomationsButton?: boolean showAutomationsButton?: boolean
/** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */ /** Deprecated: Artifacts are always available. Use showArtifactsButton for sidebar visibility. */
artifactsEnabled?: boolean 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. */ /** Only toggles the sidebar shortcut; Artifacts stay reachable from Settings. */
showArtifactsButton?: boolean showArtifactsButton?: boolean
/** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */ /** Only toggles the sidebar shortcut; Orca Mobile stays reachable from Settings. */