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:
parent
2dc172f666
commit
3ec48a74d5
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -5,6 +5,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { ArtifactListItem } from '../../shared/artifacts'
|
||||
import { ARTIFACT_HANDLERS } from './artifacts'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
ARTIFACT_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/artifact-sharing-gate'
|
||||
import { RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { reportCliError } from '../format'
|
||||
|
||||
const item: ArtifactListItem = {
|
||||
artifact: {
|
||||
|
|
@ -77,7 +84,12 @@ describe('artifact CLI handlers', () => {
|
|||
const handle = await open(join(cwd, 'oversized.html'), 'w')
|
||||
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
|
||||
await handle.close()
|
||||
const call = vi.fn()
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { artifactSharingEnabled: true } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS['artifacts share']!({
|
||||
|
|
@ -87,7 +99,8 @@ describe('artifact CLI handlers', () => {
|
|||
json: false
|
||||
})
|
||||
).rejects.toThrow(/too large/)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
// The capability preflight is the only permitted call; the oversized body never ships.
|
||||
expect(call).not.toHaveBeenCalledWith('artifacts.share', expect.anything())
|
||||
})
|
||||
|
||||
it('passes an opaque list cursor through and prints the next cursor', async () => {
|
||||
|
|
@ -115,6 +128,149 @@ describe('artifact CLI handlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it.each(['artifacts share', 'artifacts update'])(
|
||||
'denies `%s` from the capability preflight without reading or shipping the file',
|
||||
async (command) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { artifactSharingEnabled: false } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['omits the capability field', {}],
|
||||
['cannot answer the preflight', null]
|
||||
])('still attempts the publish RPC when the host %s', async (_label, settings) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'settings.get') {
|
||||
if (!settings) {
|
||||
return Promise.reject(new Error('unsupported_method'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'request-2',
|
||||
ok: true,
|
||||
result: { status: 'ok', value: item },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await ARTIFACT_HANDLERS['artifacts share']!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'artifacts.share',
|
||||
expect.objectContaining({ content: '<h1>Hi</h1>' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['artifacts share', 'artifacts update'])(
|
||||
'surfaces the capability denial from `%s` with actionable next steps',
|
||||
async (command) => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
message: ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await expect(
|
||||
ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
})
|
||||
).rejects.toMatchObject({ code: ARTIFACT_SHARING_DISABLED_CODE })
|
||||
|
||||
// The CLI entry point reports the thrown error; assert the rendered text is actionable.
|
||||
reportCliError(
|
||||
await ARTIFACT_HANDLERS[command]!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: false
|
||||
}).catch((error: unknown) => error),
|
||||
false,
|
||||
{ commandPath: command.split(' ') }
|
||||
)
|
||||
const rendered = String(errorLog.mock.calls.at(-1)?.[0])
|
||||
expect(rendered).toContain(ARTIFACT_SHARING_DISABLED_MESSAGE)
|
||||
expect(rendered).toContain('Settings → Artifacts')
|
||||
}
|
||||
)
|
||||
|
||||
it('reports the denial with a stable code in --json mode', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
|
||||
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
|
||||
const call = vi.fn().mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE,
|
||||
message: ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
data: { nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
const error = await ARTIFACT_HANDLERS['artifacts share']!({
|
||||
client: { call } as never,
|
||||
cwd,
|
||||
flags: new Map([['file', 'report.html']]),
|
||||
json: true
|
||||
}).catch((thrown: unknown) => thrown)
|
||||
reportCliError(error, true)
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls.at(-1)?.[0])).error).toMatchObject({
|
||||
code: ARTIFACT_SHARING_DISABLED_CODE
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['environment', 'pairing-code'])(
|
||||
'rejects explicit remote selector --%s',
|
||||
async (flag) => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
REMOTE_ARTIFACT_INPUT_ENV
|
||||
} from '../../shared/artifact-cli-bridge'
|
||||
import { readArtifactFileWithinLimit } from '../../shared/artifact-file-read'
|
||||
import {
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
ARTIFACT_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/artifact-sharing-gate'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
|
||||
|
|
@ -76,6 +81,31 @@ async function readStdinWithinLimit(maxBytes: number): Promise<string> {
|
|||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing is off by default, so denial is the common outcome. A tiny `settings.get` read
|
||||
* answers it before we load (or pipe in) up to the full RPC byte budget the host would reject.
|
||||
* Only an explicit `false` denies: a host predating the capability omits the field entirely,
|
||||
* and an unreachable host stays the publish RPC's problem, not the preflight's.
|
||||
*/
|
||||
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
|
||||
let enabled: unknown
|
||||
try {
|
||||
const response = await ctx.client.call<{
|
||||
settings?: { artifactSharingEnabled?: boolean }
|
||||
}>('settings.get')
|
||||
enabled = response.result?.settings?.artifactSharingEnabled
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (enabled === false) {
|
||||
throw new RuntimeClientError(
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_MESSAGE,
|
||||
{ nextSteps: [...ARTIFACT_SHARING_DISABLED_NEXT_STEPS] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRequest> {
|
||||
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
|
||||
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
|
||||
|
|
@ -83,6 +113,7 @@ async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRe
|
|||
if (!contentType) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.')
|
||||
}
|
||||
await preflightPublishCapability(ctx)
|
||||
const localRead = remoteInput
|
||||
? null
|
||||
: await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]) }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -278,6 +278,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
showTasksButton: true,
|
||||
showAutomationsButton: true,
|
||||
artifactsEnabled: true,
|
||||
artifactSharingEnabled: false,
|
||||
showArtifactsButton: false,
|
||||
showMobileButton: true,
|
||||
showPinnedWorktreesInGroups: false,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue